Skip to content

Commit 8dc4feb

Browse files
committed
True TOML config support
Signed-off-by: Bernát Gábor <[email protected]>
1 parent f5eba31 commit 8dc4feb

File tree

9 files changed

+331
-39
lines changed

9 files changed

+331
-39
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ repos:
2424
hooks:
2525
- id: pyproject-fmt
2626
- repo: https://github.com/astral-sh/ruff-pre-commit
27-
rev: "v0.6.7"
27+
rev: "v0.6.8"
2828
hooks:
2929
- id: ruff-format
3030
- id: ruff
@@ -39,12 +39,9 @@ repos:
3939
hooks:
4040
- id: rst-backticks
4141
- repo: https://github.com/rbubley/mirrors-prettier
42-
rev: "v3.3.3" # Use the sha / tag you want to point at
42+
rev: "v3.3.3"
4343
hooks:
4444
- id: prettier
45-
additional_dependencies:
46-
47-
- "@prettier/[email protected]"
4845
- repo: local
4946
hooks:
5047
- id: changelogs-rst

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`.

pyproject.toml

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,20 @@ dependencies = [
5353
"cachetools>=5.5",
5454
"chardet>=5.2",
5555
"colorama>=0.4.6",
56-
"filelock>=3.15.4",
56+
"filelock>=3.16.1",
5757
"packaging>=24.1",
58-
"platformdirs>=4.2.2",
58+
"platformdirs>=4.3.6",
5959
"pluggy>=1.5",
60-
"pyproject-api>=1.7.1",
60+
"pyproject-api>=1.8",
6161
"tomli>=2.0.1; python_version<'3.11'",
62-
"virtualenv>=20.26.3",
62+
"typing-extensions>=4.12.2; python_version<'3.11'",
63+
"virtualenv>=20.26.6",
6364
]
6465
optional-dependencies.docs = [
6566
"furo>=2024.8.6",
6667
"sphinx>=8.0.2",
67-
"sphinx-argparse-cli>=1.17",
68-
"sphinx-autodoc-typehints>=2.4",
68+
"sphinx-argparse-cli>=1.18.2",
69+
"sphinx-autodoc-typehints>=2.4.4",
6970
"sphinx-copybutton>=0.5.2",
7071
"sphinx-inline-tabs>=2023.4.21",
7172
"sphinxcontrib-towncrier>=0.2.1a0",
@@ -75,19 +76,19 @@ optional-dependencies.testing = [
7576
"build[virtualenv]>=1.2.2",
7677
"covdefaults>=2.3",
7778
"detect-test-pollution>=1.2",
78-
"devpi-process>=1",
79-
"diff-cover>=9.1.1",
79+
"devpi-process>=1.0.2",
80+
"diff-cover>=9.2",
8081
"distlib>=0.3.8",
8182
"flaky>=3.8.1",
8283
"hatch-vcs>=0.4",
8384
"hatchling>=1.25",
8485
"psutil>=6",
85-
"pytest>=8.3.2",
86+
"pytest>=8.3.3",
8687
"pytest-cov>=5",
8788
"pytest-mock>=3.14",
8889
"pytest-xdist>=3.6.1",
8990
"re-assert>=1.1",
90-
"setuptools>=74.1.2",
91+
"setuptools>=75.1",
9192
"time-machine>=2.15; implementation_name!='pypy'",
9293
"wheel>=0.44",
9394
]

src/tox/config/loader/toml.py

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

src/tox/config/source/discover.py

Lines changed: 9 additions & 2 deletions
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:
@@ -79,7 +85,8 @@ def _create_default_source(root_dir: Path | None) -> Source:
7985
break
8086
else: # if not set use where we find pyproject.toml in the tree or cwd
8187
empty = root_dir
82-
logging.warning("No %s found, assuming empty tox.ini at %s", " or ".join(i.FILENAME for i in SOURCE_TYPES), empty)
88+
names = " or ".join({i.FILENAME: None for i in SOURCE_TYPES})
89+
logging.warning("No %s found, assuming empty tox.ini at %s", names, empty)
8390
return ToxIni(empty / "tox.ini", content="")
8491

8592

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+
]

tests/config/loader/toml/test_toml_loader.py

Whitespace-only changes.

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)