Skip to content

Commit 9a8a916

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

File tree

15 files changed

+636
-44
lines changed

15 files changed

+636
-44
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: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from inspect import isclass
5+
from pathlib import Path
6+
from typing import (
7+
TYPE_CHECKING,
8+
Any,
9+
Dict,
10+
Iterator,
11+
List,
12+
Literal,
13+
Mapping,
14+
Set,
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+
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+
unused_exclude: set[str],
48+
) -> None:
49+
self.content = content
50+
self._unused_exclude = unused_exclude
51+
super().__init__(section, overrides)
52+
53+
def __repr__(self) -> str:
54+
return f"{self.__class__.__name__}({self.section.name}, {self.content!r})"
55+
56+
def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> TomlTypes: # noqa: ARG002
57+
return self.content[key]
58+
59+
def found_keys(self) -> set[str]:
60+
return set(self.content.keys()) - self._unused_exclude
61+
62+
@staticmethod
63+
def to_str(value: TomlTypes) -> str:
64+
return _ensure_type_correct(value, str) # type: ignore[return-value] # no mypy support
65+
66+
@staticmethod
67+
def to_bool(value: TomlTypes) -> bool:
68+
return _ensure_type_correct(value, bool)
69+
70+
@staticmethod
71+
def to_list(value: TomlTypes, of_type: type[Any]) -> Iterator[_T]:
72+
of = List[of_type] # type: ignore[valid-type] # no mypy support
73+
return iter(_ensure_type_correct(value, of)) # type: ignore[call-overload,no-any-return]
74+
75+
@staticmethod
76+
def to_set(value: TomlTypes, of_type: type[Any]) -> Iterator[_T]:
77+
of = Set[of_type] # type: ignore[valid-type] # no mypy support
78+
return iter(_ensure_type_correct(value, of)) # type: ignore[call-overload,no-any-return]
79+
80+
@staticmethod
81+
def to_dict(value: TomlTypes, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[_T, _T]]:
82+
of = Dict[of_type[0], of_type[1]] # type: ignore[valid-type] # no mypy support
83+
return _ensure_type_correct(value, of).items() # type: ignore[attr-defined,no-any-return]
84+
85+
@staticmethod
86+
def to_path(value: TomlTypes) -> Path:
87+
return Path(TomlLoader.to_str(value))
88+
89+
@staticmethod
90+
def to_command(value: TomlTypes) -> Command:
91+
return Command(args=cast(List[str], value)) # validated during load in _ensure_type_correct
92+
93+
@staticmethod
94+
def to_env_list(value: TomlTypes) -> EnvList:
95+
return EnvList(envs=list(TomlLoader.to_list(value, str)))
96+
97+
98+
_T = TypeVar("_T")
99+
100+
101+
def _ensure_type_correct(val: TomlTypes, of_type: type[_T]) -> TypeGuard[_T]: # noqa: C901, PLR0912
102+
casting_to = getattr(of_type, "__origin__", of_type.__class__)
103+
msg = ""
104+
if casting_to in {list, List}:
105+
entry_type = of_type.__args__[0] # type: ignore[attr-defined]
106+
if isinstance(val, list):
107+
for va in val:
108+
_ensure_type_correct(va, entry_type)
109+
else:
110+
msg = f"{val!r} is not list"
111+
elif isclass(of_type) and issubclass(of_type, Command):
112+
# first we cast it to list then create commands, so for now just validate is a nested list
113+
_ensure_type_correct(val, List[str])
114+
elif casting_to in {set, Set}:
115+
entry_type = of_type.__args__[0] # type: ignore[attr-defined]
116+
if isinstance(val, set):
117+
for va in val:
118+
_ensure_type_correct(va, entry_type)
119+
else:
120+
msg = f"{val!r} is not set"
121+
elif casting_to in {dict, Dict}:
122+
key_type, value_type = of_type.__args__[0], of_type.__args__[1] # type: ignore[attr-defined]
123+
if isinstance(val, dict):
124+
for va in val:
125+
_ensure_type_correct(va, key_type)
126+
for va in val.values():
127+
_ensure_type_correct(va, value_type)
128+
else:
129+
msg = f"{val!r} is not dictionary"
130+
elif casting_to == Union: # handle Optional values
131+
args: list[type[Any]] = of_type.__args__ # type: ignore[attr-defined]
132+
for arg in args:
133+
try:
134+
_ensure_type_correct(val, arg)
135+
break
136+
except TypeError:
137+
pass
138+
else:
139+
msg = f"{val!r} is not union of {', '.join(a.__name__ for a in args)}"
140+
elif casting_to in {Literal, type(Literal)}:
141+
choice = of_type.__args__ # type: ignore[attr-defined]
142+
if val not in choice:
143+
msg = f"{val!r} is not one of literal {','.join(repr(i) for i in choice)}"
144+
elif not isinstance(val, of_type):
145+
msg = f"{val!r} is not of type {of_type.__name__!r}"
146+
if msg:
147+
raise TypeError(msg)
148+
return cast(_T, val) # type: ignore[return-value] # logic too complicated for mypy
149+
150+
151+
__all__ = [
152+
"TomlLoader",
153+
]

src/tox/config/source/api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def __init__(self, path: Path) -> None:
2323
self.path: Path = path #: the path to the configuration source
2424
self._section_to_loaders: dict[str, list[Loader[Any]]] = {}
2525

26+
def __repr__(self) -> str:
27+
return f"{self.__class__.__name__}(path={self.path})"
28+
2629
def get_loaders(
2730
self,
2831
section: Section,

src/tox/config/source/discover.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,20 @@
99

1010
from .legacy_toml import LegacyToml
1111
from .setup_cfg import SetupCfg
12+
from .toml_pyproject import TomlPyProject
13+
from .toml_tox import TomlTox
1214
from .tox_ini import ToxIni
1315

1416
if TYPE_CHECKING:
1517
from .api import Source
1618

17-
SOURCE_TYPES: tuple[type[Source], ...] = (ToxIni, SetupCfg, LegacyToml)
19+
SOURCE_TYPES: tuple[type[Source], ...] = (
20+
ToxIni,
21+
SetupCfg,
22+
LegacyToml,
23+
TomlPyProject,
24+
TomlTox,
25+
)
1826

1927

2028
def discover_source(config_file: Path | None, root_dir: Path | None) -> Source:
@@ -79,7 +87,8 @@ def _create_default_source(root_dir: Path | None) -> Source:
7987
break
8088
else: # if not set use where we find pyproject.toml in the tree or cwd
8189
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)
90+
names = " or ".join({i.FILENAME: None for i in SOURCE_TYPES})
91+
logging.warning("No %s found, assuming empty tox.ini at %s", names, empty)
8392
return ToxIni(empty / "tox.ini", content="")
8493

8594

src/tox/config/source/ini.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,6 @@ def _discover_from_section(self, section: IniSection, known_factors: set[str]) -
107107
if set(env.split("-")) - known_factors:
108108
yield env
109109

110-
def __repr__(self) -> str:
111-
return f"{type(self).__name__}(path={self.path})"
112-
113110

114111
__all__ = [
115112
"IniSource",

0 commit comments

Comments
 (0)