Skip to content

Commit

Permalink
support tox.ini inside pyproject.toml (#977)
Browse files Browse the repository at this point in the history
* tox_legacy_ini inside pyproject.toml

* support tox.ini inside pyproject.toml
  • Loading branch information
gaborbernat authored and asottile committed Sep 14, 2018
1 parent e869df1 commit 6e33cd5
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 72 deletions.
1 change: 1 addition & 0 deletions changelog/814.feature.rst
Original file line number Diff line number Diff line change
@@ -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`
17 changes: 14 additions & 3 deletions doc/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------------

Expand Down
24 changes: 24 additions & 0 deletions doc/example/basic.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------------------------------------------

Expand Down
146 changes: 83 additions & 63 deletions src/tox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.",
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/tox/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
6 changes: 2 additions & 4 deletions src/tox/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down Expand Up @@ -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")
Expand Down
49 changes: 48 additions & 1 deletion tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion tests/unit/test_z_cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 6e33cd5

Please sign in to comment.