Skip to content

Commit edd352e

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

17 files changed

+670
-44
lines changed

.pre-commit-config.yaml

+2-5
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

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

pyproject.toml

+11-10
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
]
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING, Dict, Iterator, List, Mapping, Set, TypeVar, cast
5+
6+
from tox.config.loader.api import Loader, Override
7+
from tox.config.types import Command, EnvList
8+
9+
from ._api import TomlTypes
10+
from ._validate import validate
11+
12+
if TYPE_CHECKING:
13+
from tox.config.loader.section import Section
14+
from tox.config.main import Config
15+
16+
_T = TypeVar("_T")
17+
_V = TypeVar("_V")
18+
19+
20+
class TomlLoader(Loader[TomlTypes]):
21+
"""Load configuration from a pyproject.toml file."""
22+
23+
def __init__(
24+
self,
25+
section: Section,
26+
overrides: list[Override],
27+
content: Mapping[str, TomlTypes],
28+
unused_exclude: set[str],
29+
) -> None:
30+
self.content = content
31+
self._unused_exclude = unused_exclude
32+
super().__init__(section, overrides)
33+
34+
def __repr__(self) -> str:
35+
return f"{self.__class__.__name__}({self.section.name}, {self.content!r})"
36+
37+
def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> TomlTypes: # noqa: ARG002
38+
return self.content[key]
39+
40+
def found_keys(self) -> set[str]:
41+
return set(self.content.keys()) - self._unused_exclude
42+
43+
@staticmethod
44+
def to_str(value: TomlTypes) -> str:
45+
return validate(value, str) # type: ignore[return-value] # no mypy support
46+
47+
@staticmethod
48+
def to_bool(value: TomlTypes) -> bool:
49+
return validate(value, bool)
50+
51+
@staticmethod
52+
def to_list(value: TomlTypes, of_type: type[_T]) -> Iterator[_T]:
53+
of = List[of_type] # type: ignore[valid-type] # no mypy support
54+
return iter(validate(value, of)) # type: ignore[call-overload,no-any-return]
55+
56+
@staticmethod
57+
def to_set(value: TomlTypes, of_type: type[_T]) -> Iterator[_T]:
58+
of = Set[of_type] # type: ignore[valid-type] # no mypy support
59+
return iter(validate(value, of)) # type: ignore[call-overload,no-any-return]
60+
61+
@staticmethod
62+
def to_dict(value: TomlTypes, of_type: tuple[type[_T], type[_V]]) -> Iterator[tuple[_T, _V]]:
63+
of = Dict[of_type[0], of_type[1]] # type: ignore[valid-type] # no mypy support
64+
return validate(value, of).items() # type: ignore[attr-defined,no-any-return]
65+
66+
@staticmethod
67+
def to_path(value: TomlTypes) -> Path:
68+
return Path(TomlLoader.to_str(value))
69+
70+
@staticmethod
71+
def to_command(value: TomlTypes) -> Command:
72+
return Command(args=cast(List[str], value)) # validated during load in _ensure_type_correct
73+
74+
@staticmethod
75+
def to_env_list(value: TomlTypes) -> EnvList:
76+
return EnvList(envs=list(TomlLoader.to_list(value, str)))
77+
78+
79+
__all__ = [
80+
"TomlLoader",
81+
]

src/tox/config/loader/toml/_api.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Dict, List, Union
4+
5+
if TYPE_CHECKING:
6+
import sys
7+
8+
if sys.version_info >= (3, 10): # pragma: no cover (py310+)
9+
from typing import TypeAlias
10+
else: # pragma: no cover (py310+)
11+
from typing_extensions import TypeAlias
12+
13+
TomlTypes: TypeAlias = Union[Dict[str, "TomlTypes"], List["TomlTypes"], str, int, float, bool, None]
14+
15+
__all__ = [
16+
"TomlTypes",
17+
]
+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
from inspect import isclass
4+
from typing import (
5+
TYPE_CHECKING,
6+
Any,
7+
Dict,
8+
List,
9+
Literal,
10+
Set,
11+
TypeVar,
12+
Union,
13+
cast,
14+
)
15+
16+
from tox.config.types import Command
17+
18+
if TYPE_CHECKING:
19+
import sys
20+
21+
from ._api import TomlTypes
22+
23+
if sys.version_info >= (3, 11): # pragma: no cover (py311+)
24+
from typing import TypeGuard
25+
else: # pragma: no cover (py311+)
26+
from typing_extensions import TypeGuard
27+
28+
T = TypeVar("T")
29+
30+
31+
def validate(val: TomlTypes, of_type: type[T]) -> TypeGuard[T]: # noqa: C901, PLR0912
32+
casting_to = getattr(of_type, "__origin__", of_type.__class__)
33+
msg = ""
34+
if casting_to in {list, List}:
35+
entry_type = of_type.__args__[0] # type: ignore[attr-defined]
36+
if isinstance(val, list):
37+
for va in val:
38+
validate(va, entry_type)
39+
else:
40+
msg = f"{val!r} is not list"
41+
elif isclass(of_type) and issubclass(of_type, Command):
42+
# first we cast it to list then create commands, so for now validate it as a nested list
43+
validate(val, List[str])
44+
elif casting_to in {set, Set}:
45+
entry_type = of_type.__args__[0] # type: ignore[attr-defined]
46+
if isinstance(val, set):
47+
for va in val:
48+
validate(va, entry_type)
49+
else:
50+
msg = f"{val!r} is not set"
51+
elif casting_to in {dict, Dict}:
52+
key_type, value_type = of_type.__args__[0], of_type.__args__[1] # type: ignore[attr-defined]
53+
if isinstance(val, dict):
54+
for va in val:
55+
validate(va, key_type)
56+
for va in val.values():
57+
validate(va, value_type)
58+
else:
59+
msg = f"{val!r} is not dictionary"
60+
elif casting_to == Union: # handle Optional values
61+
args: list[type[Any]] = of_type.__args__ # type: ignore[attr-defined]
62+
for arg in args:
63+
try:
64+
validate(val, arg)
65+
break
66+
except TypeError:
67+
pass
68+
else:
69+
msg = f"{val!r} is not union of {', '.join(a.__name__ for a in args)}"
70+
elif casting_to in {Literal, type(Literal)}:
71+
choice = of_type.__args__ # type: ignore[attr-defined]
72+
if val not in choice:
73+
msg = f"{val!r} is not one of literal {','.join(repr(i) for i in choice)}"
74+
elif not isinstance(val, of_type):
75+
msg = f"{val!r} is not of type {of_type.__name__!r}"
76+
if msg:
77+
raise TypeError(msg)
78+
return cast(T, val) # type: ignore[return-value] # logic too complicated for mypy
79+
80+
81+
__all__ = [
82+
"validate",
83+
]

src/tox/config/source/api.py

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

+11-2
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

-3
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)