Skip to content

Commit f2a6f74

Browse files
committed
True TOML config support
Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>
1 parent 329db63 commit f2a6f74

File tree

6 files changed

+311
-1
lines changed

6 files changed

+311
-1
lines changed

docs/changelog/999.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Native TOML configuration support - by :user:`gaborbernat`.

src/tox/config/loader/toml.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pathlib import Path
5+
from typing import (
6+
TYPE_CHECKING,
7+
Any,
8+
Dict,
9+
Iterator,
10+
List,
11+
Literal,
12+
Mapping,
13+
Set,
14+
TypeAlias,
15+
TypeVar,
16+
Union,
17+
cast,
18+
)
19+
20+
from tox.config.loader.api import Loader, Override
21+
from tox.config.types import Command, EnvList
22+
from tox.report import HandledError
23+
24+
if TYPE_CHECKING:
25+
from tox.config.loader.section import Section
26+
from tox.config.main import Config
27+
28+
if sys.version_info >= (3, 11): # pragma: no cover (py311+)
29+
from typing import TypeGuard
30+
else: # pragma: no cover (py311+)
31+
from typing_extensions import TypeGuard
32+
if sys.version_info >= (3, 10): # pragma: no cover (py310+)
33+
from typing import TypeGuard
34+
else: # pragma: no cover (py310+)
35+
from typing_extensions import TypeAlias
36+
37+
TomlTypes: TypeAlias = Dict[str, "TomlTypes"] | list["TomlTypes"] | str | int | float | bool | None
38+
39+
40+
class TomlLoader(Loader[TomlTypes]):
41+
"""Load configuration from a pyproject.toml file."""
42+
43+
def __init__(
44+
self,
45+
section: Section,
46+
overrides: list[Override],
47+
content: Mapping[str, TomlTypes],
48+
) -> None:
49+
if not isinstance(content, Mapping):
50+
msg = f"tox.{section.key} must be a mapping"
51+
raise HandledError(msg)
52+
self.content = content
53+
super().__init__(section, overrides)
54+
55+
def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> TomlTypes: # noqa: ARG002
56+
return self.content[key]
57+
58+
def found_keys(self) -> set[str]:
59+
return set(self.content.keys())
60+
61+
@staticmethod
62+
def to_str(value: TomlTypes) -> str:
63+
return _ensure_type_correct(value, str) # type: ignore[return-value] # no mypy support
64+
65+
@staticmethod
66+
def to_bool(value: TomlTypes) -> bool:
67+
return _ensure_type_correct(value, bool)
68+
69+
@staticmethod
70+
def to_list(value: TomlTypes, of_type: type[Any]) -> Iterator[_T]:
71+
of = List[of_type] # type: ignore[valid-type] # no mypy support
72+
return iter(_ensure_type_correct(value, of)) # type: ignore[call-overload,no-any-return]
73+
74+
@staticmethod
75+
def to_set(value: TomlTypes, of_type: type[Any]) -> Iterator[_T]:
76+
of = Set[of_type] # type: ignore[valid-type] # no mypy support
77+
return iter(_ensure_type_correct(value, of)) # type: ignore[call-overload,no-any-return]
78+
79+
@staticmethod
80+
def to_dict(value: TomlTypes, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[_T, _T]]:
81+
of = Mapping[of_type[0], of_type[1]] # type: ignore[valid-type] # no mypy support
82+
return _ensure_type_correct(value, of).items() # type: ignore[type-abstract,attr-defined,no-any-return]
83+
84+
@staticmethod
85+
def to_path(value: TomlTypes) -> Path:
86+
return Path(TomlLoader.to_str(value))
87+
88+
@staticmethod
89+
def to_command(value: TomlTypes) -> Command:
90+
return Command(args=cast(list[str], value)) # validated during load in _ensure_type_correct
91+
92+
@staticmethod
93+
def to_env_list(value: TomlTypes) -> EnvList:
94+
return EnvList(envs=list(TomlLoader.to_list(value, str)))
95+
96+
97+
_T = TypeVar("_T")
98+
99+
100+
def _ensure_type_correct(val: TomlTypes, of_type: type[_T]) -> TypeGuard[_T]: # noqa: C901, PLR0912
101+
casting_to = getattr(of_type, "__origin__", of_type.__class__)
102+
msg = ""
103+
if casting_to in {list, List}:
104+
entry_type = of_type.__args__[0] # type: ignore[attr-defined]
105+
if not (isinstance(val, list) and all(_ensure_type_correct(v, entry_type) for v in val)):
106+
msg = f"{val} is not list"
107+
elif issubclass(of_type, Command):
108+
# first we cast it to list then create commands, so for now just validate is a nested list
109+
_ensure_type_correct(val, list[str])
110+
elif casting_to in {set, Set}:
111+
entry_type = of_type.__args__[0] # type: ignore[attr-defined]
112+
if not (isinstance(val, set) and all(_ensure_type_correct(v, entry_type) for v in val)):
113+
msg = f"{val} is not set"
114+
elif casting_to in {dict, Dict}:
115+
key_type, value_type = of_type.__args__[0], of_type.__args__[1] # type: ignore[attr-defined]
116+
if not (
117+
isinstance(val, dict)
118+
and all(
119+
_ensure_type_correct(dict_key, key_type) and _ensure_type_correct(dict_value, value_type)
120+
for dict_key, dict_value in val.items()
121+
)
122+
):
123+
msg = f"{val} is not dictionary"
124+
elif casting_to == Union: # handle Optional values
125+
args: list[type[Any]] = of_type.__args__ # type: ignore[attr-defined]
126+
for arg in args:
127+
try:
128+
_ensure_type_correct(val, arg)
129+
break
130+
except TypeError:
131+
pass
132+
else:
133+
msg = f"{val} is not union of {args}"
134+
elif casting_to in {Literal, type(Literal)}:
135+
choice = of_type.__args__ # type: ignore[attr-defined]
136+
if val not in choice:
137+
msg = f"{val} is not one of literal {choice}"
138+
elif not isinstance(val, of_type):
139+
msg = f"{val} is not one of {of_type}"
140+
if msg:
141+
raise TypeError(msg)
142+
return cast(_T, val) # type: ignore[return-value] # logic too complicated for mypy
143+
144+
145+
__all__ = [
146+
"TomlLoader",
147+
]

src/tox/config/source/discover.py

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

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

1415
if TYPE_CHECKING:
1516
from .api import Source
1617

17-
SOURCE_TYPES: tuple[type[Source], ...] = (ToxIni, SetupCfg, LegacyToml)
18+
SOURCE_TYPES: tuple[type[Source], ...] = (
19+
ToxIni,
20+
SetupCfg,
21+
LegacyToml,
22+
Toml,
23+
)
1824

1925

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

src/tox/config/source/toml.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Load."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from typing import TYPE_CHECKING, Any, Iterator, Mapping, cast
7+
8+
from tox.config.loader.section import Section
9+
from tox.config.loader.toml import TomlLoader
10+
11+
from .api import Source
12+
13+
if sys.version_info >= (3, 11): # pragma: no cover (py311+)
14+
import tomllib
15+
else: # pragma: no cover (py311+)
16+
import tomli as tomllib
17+
18+
if TYPE_CHECKING:
19+
from collections.abc import Iterable
20+
from pathlib import Path
21+
22+
from tox.config.loader.api import Loader, OverrideMap
23+
from tox.config.sets import CoreConfigSet
24+
25+
TEST_ENV_PREFIX = "env"
26+
27+
28+
class TomlSection(Section):
29+
SEP = "."
30+
31+
@classmethod
32+
def test_env(cls, name: str) -> TomlSection:
33+
return cls(f"tox{cls.SEP}{name}", name)
34+
35+
@property
36+
def is_test_env(self) -> bool:
37+
return self.prefix == TEST_ENV_PREFIX
38+
39+
@property
40+
def keys(self) -> Iterable[str]:
41+
return self.key.split(self.SEP)
42+
43+
44+
class Toml(Source):
45+
"""Configuration sourced from a pyproject.toml files."""
46+
47+
FILENAME = "pyproject.toml"
48+
49+
def __init__(self, path: Path) -> None:
50+
if path.name != self.FILENAME or not path.exists():
51+
raise ValueError
52+
with path.open("rb") as file_handler:
53+
toml_content = tomllib.load(file_handler)
54+
try:
55+
content: Mapping[str, Any] = toml_content["tool"]["tox"]
56+
if "legacy_tox_ini" in content:
57+
msg = "legacy_tox_ini"
58+
raise KeyError(msg) # noqa: TRY301
59+
self._content = content
60+
except KeyError as exc:
61+
raise ValueError(path) from exc
62+
super().__init__(path)
63+
64+
def __repr__(self) -> str:
65+
return f"{self.__class__.__name__}({self.path!r})"
66+
67+
def get_core_section(self) -> Section: # noqa: PLR6301
68+
return TomlSection(prefix=None, name="tox")
69+
70+
def transform_section(self, section: Section) -> Section: # noqa: PLR6301
71+
return TomlSection(section.prefix, section.name)
72+
73+
def get_loader(self, section: Section, override_map: OverrideMap) -> Loader[Any] | None:
74+
current = self._content
75+
for at, key in enumerate(cast(TomlSection, section).keys):
76+
if at == 0:
77+
if key != "tox":
78+
msg = "Internal error, first key is not tox"
79+
raise RuntimeError(msg)
80+
elif key in current:
81+
current = current[key]
82+
else:
83+
return None
84+
return TomlLoader(
85+
section=section,
86+
overrides=override_map.get(section.key, []),
87+
content=current,
88+
)
89+
90+
def envs(self, core_conf: CoreConfigSet) -> Iterator[str]:
91+
yield from core_conf["env_list"]
92+
yield from [i.key for i in self.sections()]
93+
94+
def sections(self) -> Iterator[Section]:
95+
for env_name in self._content.get("env", {}):
96+
yield TomlSection.from_key(env_name)
97+
98+
def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: # noqa: PLR6301, ARG002
99+
yield from [TomlSection.from_key(b) for b in base]
100+
101+
def get_tox_env_section(self, item: str) -> tuple[Section, list[str], list[str]]: # noqa: PLR6301
102+
return TomlSection.test_env(item), ["tox.env_base"], ["tox.pkgenv"]
103+
104+
105+
__all__ = [
106+
"Toml",
107+
]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Callable
4+
5+
from tox.config.loader.ini import IniLoader
6+
from tox.config.source.ini_section import IniSection
7+
8+
if TYPE_CHECKING:
9+
from configparser import ConfigParser
10+
11+
12+
def test_ini_loader_keys(mk_ini_conf: Callable[[str], ConfigParser]) -> None:
13+
core = IniSection(None, "tox")
14+
loader = IniLoader(core, mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [], core_section=core)
15+
assert loader.found_keys() == {"a", "c"}

tests/config/source/test_toml.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
if TYPE_CHECKING:
6+
from tox.pytest import ToxProjectCreator
7+
8+
9+
def test_conf_in_legacy_toml(tox_project: ToxProjectCreator) -> None:
10+
project = tox_project({
11+
"pyproject.toml": """
12+
[tool.tox]
13+
env_list = [ "A", "B"]
14+
15+
[tool.tox.env_base]
16+
description = "Do magical things"
17+
commands = [
18+
["python", "--version"],
19+
["python", "-c", "import sys; print(sys.executable)"]
20+
]
21+
22+
[tool.tox.env.C]
23+
description = "Do magical things in C"
24+
commands = [
25+
["python", "--version"]
26+
]
27+
"""
28+
})
29+
30+
outcome = project.run("c", "--core", "-k", "commands")
31+
outcome.assert_success()
32+
33+
outcome = project.run("c", "-e", "C,3.13")
34+
outcome.assert_success()

0 commit comments

Comments
 (0)