Skip to content

Commit 56e4c32

Browse files
committed
[WIP] Support tox.toml
1 parent e6b9803 commit 56e4c32

File tree

12 files changed

+480
-7
lines changed

12 files changed

+480
-7
lines changed

src/tox/config/loader/toml.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Loader for TOML configuration files.
2+
3+
This is experimental API! Expect things to be broken.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING, Any, Iterator, Sequence
10+
11+
from tox.config.types import Command, EnvList
12+
13+
from .api import Loader, Override
14+
from .str_convert import StrConvert
15+
16+
if TYPE_CHECKING:
17+
from tox.config.main import Config
18+
19+
from .section import Section
20+
21+
22+
# TODO: Use MemoryLoader instead?
23+
class TomlLoader(Loader[Any]):
24+
"""Load configuration from data parsed from TOML file.
25+
26+
This is experimental API! Expect things to be broken.
27+
"""
28+
29+
def __init__(
30+
self,
31+
section: Section,
32+
raw: dict[str, Any],
33+
overrides: list[Override],
34+
core_section: Section,
35+
section_key: str | None = None,
36+
) -> None:
37+
super().__init__(section, overrides)
38+
self.raw = raw
39+
# TODO: These are probably useless for TOML loader. Copied from IniLoader.
40+
self.core_section = core_section
41+
self._section_key = section_key
42+
43+
def __repr__(self) -> str:
44+
return f"{self.__class__.__name__}(section={self._section.key}, overrides={self.overrides!r})"
45+
46+
def load_raw(self, key: Any, conf: Config | None, env_name: str | None) -> Any: # noqa: ARG002
47+
return self.raw[self._section_key or self.section.key][key]
48+
49+
def found_keys(self) -> set[str]:
50+
return set(self.raw[self._section_key or self.section.key])
51+
52+
def get_section(self, name: str) -> Any:
53+
# TODO: Make this part of API?
54+
# needed for non tox environment replacements
55+
if name in self.raw:
56+
return self.raw[name]
57+
return None
58+
59+
# TODO: Mostly duplicates MemoryLoader
60+
@staticmethod
61+
def to_bool(value: Any) -> bool:
62+
return bool(value)
63+
64+
@staticmethod
65+
def to_str(value: Any) -> str:
66+
return str(value)
67+
68+
@staticmethod
69+
def to_list(value: Any, of_type: type[Any]) -> Iterator[Any]: # noqa: ARG004
70+
return iter(value)
71+
72+
@staticmethod
73+
def to_set(value: Any, of_type: type[Any]) -> Iterator[Any]: # noqa: ARG004
74+
return iter(value)
75+
76+
@staticmethod
77+
def to_dict(value: Any, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[Any, Any]]: # noqa: ARG004
78+
return value.items() # type: ignore[no-any-return]
79+
80+
@staticmethod
81+
def to_path(value: Any) -> Path:
82+
return Path(value)
83+
84+
@staticmethod
85+
def to_command(value: Any) -> Command:
86+
if isinstance(value, Command):
87+
return value
88+
if isinstance(value, str):
89+
return StrConvert.to_command(value)
90+
raise TypeError(value)
91+
92+
@staticmethod
93+
def to_env_list(value: Any) -> EnvList:
94+
if isinstance(value, EnvList):
95+
return value
96+
if isinstance(value, str):
97+
return StrConvert.to_env_list(value)
98+
if isinstance(value, Sequence):
99+
return EnvList(value)
100+
raise TypeError(value)

src/tox/config/source/discover.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@
99

1010
from .legacy_toml import LegacyToml
1111
from .setup_cfg import SetupCfg
12+
from .toml import ToxToml
1213
from .tox_ini import ToxIni
1314

15+
# from .toml import PyProjectToml
16+
1417
if TYPE_CHECKING:
1518
from .api import Source
1619

17-
SOURCE_TYPES: tuple[type[Source], ...] = (ToxIni, SetupCfg, LegacyToml)
20+
# SOURCE_TYPES: tuple[type[Source], ...] = (ToxIni, ToxToml, PyProjectToml, SetupCfg, LegacyToml)
21+
SOURCE_TYPES: tuple[type[Source], ...] = (ToxIni, ToxToml, SetupCfg, LegacyToml)
1822

1923

2024
def discover_source(config_file: Path | None, root_dir: Path | None) -> Source:

src/tox/config/source/toml.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Support for TOML config sources.
2+
3+
This is experimental API! Expect things to be broken.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from collections import defaultdict
9+
from itertools import chain
10+
from typing import TYPE_CHECKING, Iterable, Iterator
11+
12+
import tomllib
13+
14+
from tox.config.loader.ini.factor import find_envs
15+
from tox.config.loader.section import Section
16+
from tox.config.loader.toml import TomlLoader
17+
18+
from .api import Source
19+
from .ini_section import CORE, PKG_ENV_PREFIX, TEST_ENV_PREFIX, IniSection
20+
21+
if TYPE_CHECKING:
22+
from pathlib import Path
23+
24+
from tox.config.loader.api import OverrideMap
25+
from tox.config.sets import ConfigSet
26+
27+
28+
class TomlSource(Source):
29+
"""Configuration sourced from a toml file (such as tox.toml).
30+
31+
This is experimental API! Expect things to be broken.
32+
"""
33+
34+
CORE_SECTION = CORE
35+
36+
def __init__(self, path: Path, content: str | None = None) -> None:
37+
super().__init__(path)
38+
if content is None:
39+
if not path.exists():
40+
raise ValueError
41+
content = path.read_text()
42+
self._raw = tomllib.loads(content)
43+
self._section_mapping: defaultdict[str, list[str]] = defaultdict(list)
44+
45+
def __repr__(self) -> str:
46+
return f"{type(self).__name__}(path={self.path})"
47+
48+
def transform_section(self, section: Section) -> Section: # noqa: PLR6301
49+
return IniSection(section.prefix, section.name)
50+
51+
def get_loader(self, section: Section, override_map: OverrideMap) -> TomlLoader | None:
52+
# look up requested section name in the generative testenv mapping to find the real config source
53+
for key in self._section_mapping.get(section.name) or []:
54+
if section.prefix is None or Section.from_key(key).prefix == section.prefix:
55+
break
56+
else:
57+
# if no matching section/prefix is found, use the requested section key as-is (for custom prefixes)
58+
key = section.key
59+
if key in self._raw:
60+
return TomlLoader(
61+
section=section,
62+
raw=self._raw,
63+
overrides=override_map.get(section.key, []),
64+
core_section=self.CORE_SECTION,
65+
section_key=key,
66+
)
67+
return None
68+
69+
def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: # noqa: PLR6301
70+
for a_base in base:
71+
section = IniSection.from_key(a_base)
72+
yield section # the base specifier is explicit
73+
if in_section.prefix is not None: # no prefix specified, so this could imply our own prefix
74+
yield IniSection(in_section.prefix, a_base)
75+
76+
def sections(self) -> Iterator[IniSection]:
77+
for key in self._raw:
78+
yield IniSection.from_key(key)
79+
80+
def envs(self, core_config: ConfigSet) -> Iterator[str]:
81+
seen = set()
82+
for name in self._discover_tox_envs(core_config):
83+
if name not in seen:
84+
seen.add(name)
85+
yield name
86+
87+
def _discover_tox_envs(self, core_config: ConfigSet) -> Iterator[str]:
88+
def register_factors(envs: Iterable[str]) -> None:
89+
known_factors.update(chain.from_iterable(e.split("-") for e in envs))
90+
91+
explicit = list(core_config["env_list"])
92+
yield from explicit
93+
known_factors: set[str] = set()
94+
register_factors(explicit)
95+
96+
# discover all additional defined environments, including generative section headers
97+
for section in self.sections():
98+
if section.is_test_env:
99+
register_factors(section.names)
100+
for name in section.names:
101+
self._section_mapping[name].append(section.key)
102+
yield name
103+
# add all conditional markers that are not part of the explicitly defined sections
104+
for section in self.sections():
105+
yield from self._discover_from_section(section, known_factors)
106+
107+
def _discover_from_section(self, section: IniSection, known_factors: set[str]) -> Iterator[str]:
108+
for value in self._raw[section.key].values():
109+
if isinstance(value, bool):
110+
# It's not a value with env definition.
111+
continue
112+
# XXX: We munch the value to multiline string to parse it by the library utils.
113+
merged_value = "\n".join(str(v) for v in tuple(value))
114+
for env in find_envs(merged_value):
115+
if set(env.split("-")) - known_factors:
116+
yield env
117+
118+
def get_tox_env_section(self, item: str) -> tuple[Section, list[str], list[str]]: # noqa: PLR6301
119+
return IniSection.test_env(item), [TEST_ENV_PREFIX], [PKG_ENV_PREFIX]
120+
121+
def get_core_section(self) -> Section:
122+
return self.CORE_SECTION
123+
124+
125+
class ToxToml(TomlSource):
126+
"""Configuration sourced from a tox.toml file.
127+
128+
This is experimental API! Expect things to be broken.
129+
"""
130+
131+
FILENAME = "tox.toml"
132+
133+
134+
# TODO: Section model is way too configparser precific for this to work easily.
135+
# class PyProjectToml(TomlSource):
136+
# """Configuration sourced from a pyproject.toml file.
137+
138+
# This is experimental API! Expect things to be broken.
139+
# """
140+
141+
# FILENAME = "pyproject.toml"
142+
# CORE_SECTION = IniSection("tool", "tox")

tests/config/cli/test_cli_ini.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,16 @@ def test_cli_ini_with_interpolated(tmp_path: Path, monkeypatch: MonkeyPatch) ->
145145
[
146146
pytest.param("", "tox.ini", "[tox]", id="ini-dir"),
147147
pytest.param("tox.ini", "tox.ini", "[tox]", id="ini"),
148+
pytest.param("", "tox.toml", "[tox]", id="toml-dir"),
149+
pytest.param("tox.toml", "tox.toml", "[tox]", id="toml"),
148150
pytest.param("", "setup.cfg", "[tox:tox]", id="cfg-dir"),
149151
pytest.param("setup.cfg", "setup.cfg", "[tox:tox]", id="cfg"),
150-
pytest.param("", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-dir"),
151-
pytest.param("pyproject.toml", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml"),
152+
# pytest.param("", "pyproject.toml", '[tool.tox]', id="toml-dir"),
153+
# pytest.param("pyproject.toml", "pyproject.toml", '[tool.tox]', id="toml"),
154+
pytest.param("", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-legacy-dir"),
155+
pytest.param(
156+
"pyproject.toml", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-legacy"
157+
),
152158
],
153159
)
154160
def test_conf_arg(tmp_path: Path, conf_arg: str, filename: str, content: str) -> None:

tests/config/conftest.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,17 @@
55
import pytest
66

77
if TYPE_CHECKING:
8-
from tests.conftest import ToxIniCreator
8+
from tests.conftest import ToxIniCreator, ToxTomlCreator
99
from tox.config.main import Config
1010

1111

1212
@pytest.fixture()
1313
def empty_config(tox_ini_conf: ToxIniCreator) -> Config:
14+
"""Make and return an empty INI config file."""
1415
return tox_ini_conf("")
16+
17+
18+
@pytest.fixture()
19+
def empty_toml_config(tox_toml_conf: ToxTomlCreator) -> Config:
20+
"""Make and return an empty TOML config file."""
21+
return tox_toml_conf("")
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any, Callable
4+
5+
import pytest
6+
import tomllib
7+
8+
if TYPE_CHECKING:
9+
from pathlib import Path
10+
11+
12+
@pytest.fixture()
13+
def mk_toml_conf(tmp_path: Path) -> Callable[[str], dict[str, Any]]:
14+
def _func(raw: str) -> dict[str, Any]:
15+
filename = tmp_path / "demo.toml"
16+
filename.write_bytes(raw.encode("utf-8")) # win32: avoid CR normalization - what you pass is what you get
17+
with filename.open("rb") as file_handler:
18+
return tomllib.load(file_handler)
19+
20+
return _func
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Callable
4+
5+
from tox.config.loader.api import ConfigLoadArgs, Override
6+
from tox.config.loader.toml import TomlLoader
7+
from tox.config.source.ini_section import IniSection
8+
9+
if TYPE_CHECKING:
10+
from configparser import ConfigParser
11+
12+
13+
def test_toml_loader_keys(mk_toml_conf: Callable[[str], ConfigParser]) -> None:
14+
core = IniSection(None, "tox")
15+
loader = TomlLoader(core, mk_toml_conf("\n[tox]\n\na='b'\nc='d'\n\n"), [], core_section=core)
16+
assert loader.found_keys() == {"a", "c"}
17+
18+
19+
def test_toml_loader_repr(mk_toml_conf: Callable[[str], ConfigParser]) -> None:
20+
core = IniSection(None, "tox")
21+
loader = TomlLoader(core, mk_toml_conf("\n[tox]\n\na='b'\nc='d'\n\n"), [Override("tox.a=1")], core_section=core)
22+
assert repr(loader) == "TomlLoader(section=tox, overrides={'a': [Override('tox.a=1')]})"
23+
24+
25+
def test_toml_loader_has_section(mk_toml_conf: Callable[[str], ConfigParser]) -> None:
26+
core = IniSection(None, "tox")
27+
loader = TomlLoader(core, mk_toml_conf("[magic]\n[tox]\n\na='b'\nc='d'\n\n"), [], core_section=core)
28+
assert loader.get_section("magic") is not None
29+
30+
31+
def test_toml_loader_has_no_section(mk_toml_conf: Callable[[str], ConfigParser]) -> None:
32+
core = IniSection(None, "tox")
33+
loader = TomlLoader(core, mk_toml_conf("[tox]\n\na='b'\nc='d'\n\n"), [], core_section=core)
34+
assert loader.get_section("magic") is None
35+
36+
37+
def test_toml_loader_raw(mk_toml_conf: Callable[[str], ConfigParser]) -> None:
38+
core = IniSection(None, "tox")
39+
args = ConfigLoadArgs([], "name", None)
40+
loader = TomlLoader(core, mk_toml_conf("[tox]\na='b'"), [], core_section=core)
41+
result = loader.load(key="a", of_type=str, conf=None, factory=None, args=args)
42+
assert result == "b"

tests/config/source/test_discover.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
def out_no_src(path: Path) -> str:
1212
return (
13-
f"ROOT: No tox.ini or setup.cfg or pyproject.toml found, assuming empty tox.ini at {path}\n"
13+
f"ROOT: No tox.ini or tox.toml or setup.cfg or pyproject.toml found, assuming empty tox.ini at {path}\n"
1414
f"default environments:\npy -> [no description]\n"
1515
)
1616

0 commit comments

Comments
 (0)