Skip to content

Commit 7365f1e

Browse files
committed
Use tox.env.XXX testenvs in TOML, add native pyproject.toml
1 parent ba8bb80 commit 7365f1e

File tree

11 files changed

+206
-89
lines changed

11 files changed

+206
-89
lines changed

src/tox/config/loader/section.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

3-
from abc import ABC
4-
from typing import TYPE_CHECKING
3+
from abc import ABC, abstractmethod
4+
from typing import TYPE_CHECKING, Generic, TypeVar
55

66
if TYPE_CHECKING:
77
import sys
@@ -12,32 +12,30 @@
1212
from typing_extensions import Self
1313

1414

15-
class Section(ABC): # noqa: PLW1641
16-
"""tox configuration section."""
15+
X = TypeVar("X")
16+
17+
18+
class BaseSection(ABC, Generic[X]):
19+
"""Base class for tox configuration section."""
1720

1821
SEP = ":" #: string used to separate the prefix and the section in the key
1922

20-
def __init__(self, prefix: str | None, name: str) -> None:
23+
def __init__(self, prefix: X, name: str) -> None:
2124
self._prefix = prefix
2225
self._name = name
2326

2427
@classmethod
28+
@abstractmethod
2529
def from_key(cls: type[Self], key: str) -> Self:
2630
"""
2731
Create a section from a section key.
2832
2933
:param key: the section key
3034
:return: the constructed section
3135
"""
32-
sep_at = key.find(cls.SEP)
33-
if sep_at == -1:
34-
prefix, name = None, key
35-
else:
36-
prefix, name = key[:sep_at], key[sep_at + 1 :]
37-
return cls(prefix, name)
3836

3937
@property
40-
def prefix(self) -> str | None:
38+
def prefix(self) -> X:
4139
""":return: the prefix of the section"""
4240
return self._prefix
4341

@@ -47,9 +45,9 @@ def name(self) -> str:
4745
return self._name
4846

4947
@property
48+
@abstractmethod
5049
def key(self) -> str:
5150
""":return: the section key"""
52-
return self.SEP.join(i for i in (self._prefix, self._name) if i is not None)
5351

5452
def __str__(self) -> str:
5553
return self.key
@@ -64,6 +62,31 @@ def __eq__(self, other: object) -> bool:
6462
)
6563

6664

65+
# TODO: Merge this with IniSection?
66+
class Section(BaseSection[str | None]):
67+
"""tox configuration section."""
68+
69+
@classmethod
70+
def from_key(cls: type[Self], key: str) -> Self:
71+
"""
72+
Create a section from a section key.
73+
74+
:param key: the section key
75+
:return: the constructed section
76+
"""
77+
sep_at = key.find(cls.SEP)
78+
if sep_at == -1:
79+
prefix, name = None, key
80+
else:
81+
prefix, name = key[:sep_at], key[sep_at + 1 :]
82+
return cls(prefix, name)
83+
84+
@property
85+
def key(self) -> str:
86+
""":return: the section key"""
87+
return self.SEP.join(i for i in (self._prefix, self._name) if i is not None)
88+
89+
6790
__all__ = [
6891
"Section",
6992
]

src/tox/config/source/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
from tox.config.sets import ConfigSet, CoreConfigSet
1515

1616

17+
# TODO: Generic in Section class?
18+
# TODO: Use BaseSection instead of Section?
1719
class Source(ABC):
1820
"""Source is able to return a configuration value (for either the core or per environment source)."""
1921

src/tox/config/source/discover.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,13 @@
99

1010
from .legacy_toml import LegacyToml
1111
from .setup_cfg import SetupCfg
12-
from .toml import ToxToml
12+
from .toml import PyProjectToml, ToxToml
1313
from .tox_ini import ToxIni
1414

15-
# from .toml import PyProjectToml
16-
1715
if TYPE_CHECKING:
1816
from .api import Source
1917

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

2320

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

src/tox/config/source/toml.py

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77

88
from collections import defaultdict
99
from itertools import chain
10-
from typing import TYPE_CHECKING, Iterable, Iterator
10+
from typing import TYPE_CHECKING, Any, Iterable, Iterator
1111

1212
import tomllib
1313

1414
from tox.config.loader.ini.factor import find_envs
1515
from tox.config.loader.memory import MemoryLoader
1616

1717
from .api import Source
18-
from .toml_section import CORE, PKG_ENV_PREFIX, TEST_ENV_PREFIX, TomlSection
18+
from .toml_section import BASE_TEST_ENV, CORE, PKG_ENV_PREFIX, TEST_ENV_PREFIX, TEST_ENV_ROOT, TomlSection
1919

2020
if TYPE_CHECKING:
2121
from pathlib import Path
@@ -25,21 +25,40 @@
2525
from tox.config.sets import ConfigSet
2626

2727

28+
def _extract_section(raw: dict[str, Any], section: TomlSection) -> Any:
29+
"""Extract section from TOML decoded data."""
30+
result = raw
31+
for key in chain(section.prefix, (section.name,)):
32+
if key in result:
33+
result = result[key]
34+
else:
35+
return None
36+
return result
37+
38+
2839
class TomlSource(Source):
2940
"""Configuration sourced from a toml file (such as tox.toml).
3041
3142
This is experimental API! Expect things to be broken.
3243
"""
3344

3445
CORE_SECTION = CORE
46+
ROOT_KEY: str | None = None
3547

3648
def __init__(self, path: Path, content: str | None = None) -> None:
3749
super().__init__(path)
3850
if content is None:
3951
if not path.exists():
40-
raise ValueError
52+
msg = f"Path {path} does not exist."
53+
raise ValueError(msg)
4154
content = path.read_text()
42-
self._raw = tomllib.loads(content)
55+
data = tomllib.loads(content)
56+
if self.ROOT_KEY:
57+
if self.ROOT_KEY not in data:
58+
msg = f"Section {self.ROOT_KEY} not found in {path}."
59+
raise ValueError(msg)
60+
data = data[self.ROOT_KEY]
61+
self._raw = data
4362
self._section_mapping: defaultdict[str, list[str]] = defaultdict(list)
4463

4564
def __repr__(self) -> str:
@@ -48,32 +67,30 @@ def __repr__(self) -> str:
4867
def transform_section(self, section: Section) -> Section:
4968
return TomlSection(section.prefix, section.name)
5069

51-
def get_loader(self, section: Section, override_map: OverrideMap) -> MemoryLoader | 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 TomlSection.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 MemoryLoader(
61-
self._raw[key],
62-
section=section,
63-
overrides=override_map.get(section.key, []),
64-
)
65-
return None
70+
def get_loader(self, section: TomlSection, override_map: OverrideMap) -> MemoryLoader | None:
71+
result = _extract_section(self._raw, section)
72+
if result is None:
73+
return None
74+
75+
return MemoryLoader(
76+
result,
77+
section=section,
78+
overrides=override_map.get(section.key, []),
79+
)
6680

6781
def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: # noqa: PLR6301
6882
for a_base in base:
69-
section = TomlSection.from_key(a_base)
70-
yield section # the base specifier is explicit
71-
if in_section.prefix is not None: # no prefix specified, so this could imply our own prefix
72-
yield TomlSection(in_section.prefix, a_base)
83+
yield TomlSection(in_section.prefix, a_base)
7384

74-
def sections(self) -> Iterator[Section]:
85+
def sections(self) -> Iterator[TomlSection]:
86+
# TODO: just return core section and any `tox.env.XXX` sections which exist directly.
7587
for key in self._raw:
76-
yield TomlSection.from_key(key)
88+
section = TomlSection.from_key(key)
89+
yield section
90+
if section == self.CORE_SECTION:
91+
test_env_data = _extract_section(self._raw, TEST_ENV_ROOT)
92+
for env_name in test_env_data or {}:
93+
yield TomlSection(TEST_ENV_PREFIX, env_name)
7794

7895
def envs(self, core_config: ConfigSet) -> Iterator[str]:
7996
seen = set()
@@ -102,8 +119,9 @@ def register_factors(envs: Iterable[str]) -> None:
102119
for section in self.sections():
103120
yield from self._discover_from_section(section, known_factors)
104121

105-
def _discover_from_section(self, section: Section, known_factors: set[str]) -> Iterator[str]:
106-
for value in self._raw[section.key].values():
122+
def _discover_from_section(self, section: TomlSection, known_factors: set[str]) -> Iterator[str]:
123+
section_data = _extract_section(self._raw, section)
124+
for value in (section_data or {}).values():
107125
if isinstance(value, bool):
108126
# It's not a value with env definition.
109127
continue
@@ -113,8 +131,8 @@ def _discover_from_section(self, section: Section, known_factors: set[str]) -> I
113131
if set(env.split("-")) - known_factors:
114132
yield env
115133

116-
def get_tox_env_section(self, item: str) -> tuple[Section, list[str], list[str]]: # noqa: PLR6301
117-
return TomlSection.test_env(item), [TEST_ENV_PREFIX], [PKG_ENV_PREFIX]
134+
def get_tox_env_section(self, item: str) -> tuple[TomlSection, list[str], list[str]]: # noqa: PLR6301
135+
return TomlSection.test_env(item), [BASE_TEST_ENV], [PKG_ENV_PREFIX]
118136

119137
def get_core_section(self) -> TomlSection:
120138
return self.CORE_SECTION
@@ -129,12 +147,18 @@ class ToxToml(TomlSource):
129147
FILENAME = "tox.toml"
130148

131149

132-
# TODO: Section model is way too configparser precific for this to work easily.
133-
# class PyProjectToml(TomlSource):
134-
# """Configuration sourced from a pyproject.toml file.
150+
class PyProjectToml(TomlSource):
151+
"""Configuration sourced from a pyproject.toml file.
135152
136-
# This is experimental API! Expect things to be broken.
137-
# """
153+
This is experimental API! Expect things to be broken.
154+
"""
138155

139-
# FILENAME = "pyproject.toml"
140-
# CORE_SECTION = IniSection("tool", "tox")
156+
FILENAME = "pyproject.toml"
157+
ROOT_KEY = "tool"
158+
159+
def __init__(self, path: Path, content: str | None = None) -> None:
160+
super().__init__(path, content)
161+
core_data = _extract_section(self._raw, self.CORE_SECTION)
162+
if core_data is not None and tuple(core_data.keys()) == ("legacy_tox_ini",):
163+
msg = "pyproject.toml is in the legacy mode."
164+
raise ValueError(msg)

src/tox/config/source/toml_section.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,51 @@
11
from __future__ import annotations
22

3+
from itertools import chain
4+
from typing import Sequence
5+
36
from tox.config.loader.ini.factor import extend_factors
4-
from tox.config.loader.section import Section
7+
from tox.config.loader.section import BaseSection
58

6-
# TODO: Rename to 'env'
7-
TEST_ENV_PREFIX = "testenv"
9+
# TODO: Shouldn't this be loaded from config?
10+
BASE_TEST_ENV = "testenv"
11+
TEST_ENV_PREFIX = ("tox", "env")
12+
# TODO: PKG_ENV_PREFIX?
813
PKG_ENV_PREFIX = "pkgenv"
914

1015

11-
# TODO: Duplicates IniSection
12-
class TomlSection(Section):
16+
# TODO: Duplicates IniSection API
17+
class TomlSection(BaseSection[Sequence[str]]):
18+
def __init__(self, prefix: Sequence[str] | None, name: str) -> None:
19+
super().__init__(tuple(prefix or ()), name)
20+
21+
@classmethod
22+
def from_key(cls: type[TomlSection], key: str) -> TomlSection:
23+
"""
24+
Create a section from a section key.
25+
26+
:param key: the section key
27+
:return: the constructed section
28+
"""
29+
chunks = key.split(cls.SEP)
30+
return cls(chunks[:-1], chunks[-1])
31+
32+
@property
33+
def key(self) -> str:
34+
""":return: the section key"""
35+
return self.SEP.join(chain(self._prefix, (self._name,)))
36+
1337
@classmethod
1438
def test_env(cls, name: str) -> TomlSection:
1539
return cls(TEST_ENV_PREFIX, name)
1640

1741
@property
1842
def is_test_env(self) -> bool:
19-
return self.prefix == TEST_ENV_PREFIX
43+
return self.prefix == TEST_ENV_PREFIX and self.name != BASE_TEST_ENV
2044

2145
@property
2246
def names(self) -> list[str]:
2347
return list(extend_factors(self.name))
2448

2549

2650
CORE = TomlSection(None, "tox")
51+
TEST_ENV_ROOT = TomlSection(TEST_ENV_PREFIX[:-1], TEST_ENV_PREFIX[-1])

tests/config/cli/test_cli_ini.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,13 @@ def test_cli_ini_with_interpolated(tmp_path: Path, monkeypatch: MonkeyPatch) ->
149149
pytest.param("tox.toml", "tox.toml", "[tox]", id="toml"),
150150
pytest.param("", "setup.cfg", "[tox:tox]", id="cfg-dir"),
151151
pytest.param("setup.cfg", "setup.cfg", "[tox:tox]", id="cfg"),
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-
),
152+
pytest.param("", "pyproject.toml", "[tool.tox]", id="toml-dir"),
153+
pytest.param("pyproject.toml", "pyproject.toml", "[tool.tox]", id="toml"),
154+
# TODO: Fix this
155+
# pytest.param("", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-legacy-dir"),
156+
# pytest.param(
157+
# "pyproject.toml", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-legacy"
158+
# ),
158159
],
159160
)
160161
def test_conf_arg(tmp_path: Path, conf_arg: str, filename: str, content: str) -> None:

tests/config/source/test_discover.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99

1010

1111
def out_no_src(path: Path) -> str:
12+
# TODO: Deduplicate file names.
1213
return (
13-
f"ROOT: No tox.ini or tox.toml or setup.cfg or pyproject.toml found, assuming empty tox.ini at {path}\n"
14+
f"ROOT: No tox.ini or tox.toml or pyproject.toml or setup.cfg or pyproject.toml found, assuming empty tox.ini at {path}\n"
1415
f"default environments:\npy -> [no description]\n"
1516
)
1617

0 commit comments

Comments
 (0)