From edd352e55108acc8303516849dd087e748ed0a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 27 Sep 2024 15:23:28 -0700 Subject: [PATCH 1/3] True TOML config support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bernát Gábor --- .pre-commit-config.yaml | 7 +- docs/changelog/999.feature.rst | 1 + pyproject.toml | 21 +-- src/tox/config/loader/toml/__init__.py | 81 +++++++++++ src/tox/config/loader/toml/_api.py | 17 +++ src/tox/config/loader/toml/_validate.py | 83 +++++++++++ src/tox/config/source/api.py | 3 + src/tox/config/source/discover.py | 13 +- src/tox/config/source/ini.py | 3 - src/tox/config/source/toml_pyproject.py | 126 ++++++++++++++++ src/tox/config/source/toml_tox.py | 23 +++ tests/config/loader/test_toml_loader.py | 158 +++++++++++++++++++++ tests/config/source/test_discover.py | 2 +- tests/config/source/test_toml_pyproject.py | 62 ++++++++ tests/config/source/test_toml_tox.py | 67 +++++++++ tests/session/cmd/test_legacy.py | 4 +- tox.ini | 43 +++--- 17 files changed, 670 insertions(+), 44 deletions(-) create mode 100644 docs/changelog/999.feature.rst create mode 100644 src/tox/config/loader/toml/__init__.py create mode 100644 src/tox/config/loader/toml/_api.py create mode 100644 src/tox/config/loader/toml/_validate.py create mode 100644 src/tox/config/source/toml_pyproject.py create mode 100644 src/tox/config/source/toml_tox.py create mode 100644 tests/config/loader/test_toml_loader.py create mode 100644 tests/config/source/test_toml_pyproject.py create mode 100644 tests/config/source/test_toml_tox.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6f87ee3c4..4252dbc2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.7" + rev: "v0.6.8" hooks: - id: ruff-format - id: ruff @@ -39,12 +39,9 @@ repos: hooks: - id: rst-backticks - repo: https://github.com/rbubley/mirrors-prettier - rev: "v3.3.3" # Use the sha / tag you want to point at + rev: "v3.3.3" hooks: - id: prettier - additional_dependencies: - - prettier@3.3.3 - - "@prettier/plugin-xml@3.4.1" - repo: local hooks: - id: changelogs-rst diff --git a/docs/changelog/999.feature.rst b/docs/changelog/999.feature.rst new file mode 100644 index 000000000..bd4aceb3e --- /dev/null +++ b/docs/changelog/999.feature.rst @@ -0,0 +1 @@ +Native TOML configuration support - by :user:`gaborbernat`. diff --git a/pyproject.toml b/pyproject.toml index d0643293c..4f9836d6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,19 +53,20 @@ dependencies = [ "cachetools>=5.5", "chardet>=5.2", "colorama>=0.4.6", - "filelock>=3.15.4", + "filelock>=3.16.1", "packaging>=24.1", - "platformdirs>=4.2.2", + "platformdirs>=4.3.6", "pluggy>=1.5", - "pyproject-api>=1.7.1", + "pyproject-api>=1.8", "tomli>=2.0.1; python_version<'3.11'", - "virtualenv>=20.26.3", + "typing-extensions>=4.12.2; python_version<'3.11'", + "virtualenv>=20.26.6", ] optional-dependencies.docs = [ "furo>=2024.8.6", "sphinx>=8.0.2", - "sphinx-argparse-cli>=1.17", - "sphinx-autodoc-typehints>=2.4", + "sphinx-argparse-cli>=1.18.2", + "sphinx-autodoc-typehints>=2.4.4", "sphinx-copybutton>=0.5.2", "sphinx-inline-tabs>=2023.4.21", "sphinxcontrib-towncrier>=0.2.1a0", @@ -75,19 +76,19 @@ optional-dependencies.testing = [ "build[virtualenv]>=1.2.2", "covdefaults>=2.3", "detect-test-pollution>=1.2", - "devpi-process>=1", - "diff-cover>=9.1.1", + "devpi-process>=1.0.2", + "diff-cover>=9.2", "distlib>=0.3.8", "flaky>=3.8.1", "hatch-vcs>=0.4", "hatchling>=1.25", "psutil>=6", - "pytest>=8.3.2", + "pytest>=8.3.3", "pytest-cov>=5", "pytest-mock>=3.14", "pytest-xdist>=3.6.1", "re-assert>=1.1", - "setuptools>=74.1.2", + "setuptools>=75.1", "time-machine>=2.15; implementation_name!='pypy'", "wheel>=0.44", ] diff --git a/src/tox/config/loader/toml/__init__.py b/src/tox/config/loader/toml/__init__.py new file mode 100644 index 000000000..173e8395d --- /dev/null +++ b/src/tox/config/loader/toml/__init__.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Iterator, List, Mapping, Set, TypeVar, cast + +from tox.config.loader.api import Loader, Override +from tox.config.types import Command, EnvList + +from ._api import TomlTypes +from ._validate import validate + +if TYPE_CHECKING: + from tox.config.loader.section import Section + from tox.config.main import Config + +_T = TypeVar("_T") +_V = TypeVar("_V") + + +class TomlLoader(Loader[TomlTypes]): + """Load configuration from a pyproject.toml file.""" + + def __init__( + self, + section: Section, + overrides: list[Override], + content: Mapping[str, TomlTypes], + unused_exclude: set[str], + ) -> None: + self.content = content + self._unused_exclude = unused_exclude + super().__init__(section, overrides) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.section.name}, {self.content!r})" + + def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> TomlTypes: # noqa: ARG002 + return self.content[key] + + def found_keys(self) -> set[str]: + return set(self.content.keys()) - self._unused_exclude + + @staticmethod + def to_str(value: TomlTypes) -> str: + return validate(value, str) # type: ignore[return-value] # no mypy support + + @staticmethod + def to_bool(value: TomlTypes) -> bool: + return validate(value, bool) + + @staticmethod + def to_list(value: TomlTypes, of_type: type[_T]) -> Iterator[_T]: + of = List[of_type] # type: ignore[valid-type] # no mypy support + return iter(validate(value, of)) # type: ignore[call-overload,no-any-return] + + @staticmethod + def to_set(value: TomlTypes, of_type: type[_T]) -> Iterator[_T]: + of = Set[of_type] # type: ignore[valid-type] # no mypy support + return iter(validate(value, of)) # type: ignore[call-overload,no-any-return] + + @staticmethod + def to_dict(value: TomlTypes, of_type: tuple[type[_T], type[_V]]) -> Iterator[tuple[_T, _V]]: + of = Dict[of_type[0], of_type[1]] # type: ignore[valid-type] # no mypy support + return validate(value, of).items() # type: ignore[attr-defined,no-any-return] + + @staticmethod + def to_path(value: TomlTypes) -> Path: + return Path(TomlLoader.to_str(value)) + + @staticmethod + def to_command(value: TomlTypes) -> Command: + return Command(args=cast(List[str], value)) # validated during load in _ensure_type_correct + + @staticmethod + def to_env_list(value: TomlTypes) -> EnvList: + return EnvList(envs=list(TomlLoader.to_list(value, str))) + + +__all__ = [ + "TomlLoader", +] diff --git a/src/tox/config/loader/toml/_api.py b/src/tox/config/loader/toml/_api.py new file mode 100644 index 000000000..9a9596df9 --- /dev/null +++ b/src/tox/config/loader/toml/_api.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List, Union + +if TYPE_CHECKING: + import sys + + if sys.version_info >= (3, 10): # pragma: no cover (py310+) + from typing import TypeAlias + else: # pragma: no cover (py310+) + from typing_extensions import TypeAlias + +TomlTypes: TypeAlias = Union[Dict[str, "TomlTypes"], List["TomlTypes"], str, int, float, bool, None] + +__all__ = [ + "TomlTypes", +] diff --git a/src/tox/config/loader/toml/_validate.py b/src/tox/config/loader/toml/_validate.py new file mode 100644 index 000000000..e2f5534d7 --- /dev/null +++ b/src/tox/config/loader/toml/_validate.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from inspect import isclass +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Set, + TypeVar, + Union, + cast, +) + +from tox.config.types import Command + +if TYPE_CHECKING: + import sys + + from ._api import TomlTypes + + if sys.version_info >= (3, 11): # pragma: no cover (py311+) + from typing import TypeGuard + else: # pragma: no cover (py311+) + from typing_extensions import TypeGuard + +T = TypeVar("T") + + +def validate(val: TomlTypes, of_type: type[T]) -> TypeGuard[T]: # noqa: C901, PLR0912 + casting_to = getattr(of_type, "__origin__", of_type.__class__) + msg = "" + if casting_to in {list, List}: + entry_type = of_type.__args__[0] # type: ignore[attr-defined] + if isinstance(val, list): + for va in val: + validate(va, entry_type) + else: + msg = f"{val!r} is not list" + elif isclass(of_type) and issubclass(of_type, Command): + # first we cast it to list then create commands, so for now validate it as a nested list + validate(val, List[str]) + elif casting_to in {set, Set}: + entry_type = of_type.__args__[0] # type: ignore[attr-defined] + if isinstance(val, set): + for va in val: + validate(va, entry_type) + else: + msg = f"{val!r} is not set" + elif casting_to in {dict, Dict}: + key_type, value_type = of_type.__args__[0], of_type.__args__[1] # type: ignore[attr-defined] + if isinstance(val, dict): + for va in val: + validate(va, key_type) + for va in val.values(): + validate(va, value_type) + else: + msg = f"{val!r} is not dictionary" + elif casting_to == Union: # handle Optional values + args: list[type[Any]] = of_type.__args__ # type: ignore[attr-defined] + for arg in args: + try: + validate(val, arg) + break + except TypeError: + pass + else: + msg = f"{val!r} is not union of {', '.join(a.__name__ for a in args)}" + elif casting_to in {Literal, type(Literal)}: + choice = of_type.__args__ # type: ignore[attr-defined] + if val not in choice: + msg = f"{val!r} is not one of literal {','.join(repr(i) for i in choice)}" + elif not isinstance(val, of_type): + msg = f"{val!r} is not of type {of_type.__name__!r}" + if msg: + raise TypeError(msg) + return cast(T, val) # type: ignore[return-value] # logic too complicated for mypy + + +__all__ = [ + "validate", +] diff --git a/src/tox/config/source/api.py b/src/tox/config/source/api.py index 068e7c5c1..b8b703e62 100644 --- a/src/tox/config/source/api.py +++ b/src/tox/config/source/api.py @@ -23,6 +23,9 @@ def __init__(self, path: Path) -> None: self.path: Path = path #: the path to the configuration source self._section_to_loaders: dict[str, list[Loader[Any]]] = {} + def __repr__(self) -> str: + return f"{self.__class__.__name__}(path={self.path})" + def get_loaders( self, section: Section, diff --git a/src/tox/config/source/discover.py b/src/tox/config/source/discover.py index 7fcac23cd..60cd2a832 100644 --- a/src/tox/config/source/discover.py +++ b/src/tox/config/source/discover.py @@ -9,12 +9,20 @@ from .legacy_toml import LegacyToml from .setup_cfg import SetupCfg +from .toml_pyproject import TomlPyProject +from .toml_tox import TomlTox from .tox_ini import ToxIni if TYPE_CHECKING: from .api import Source -SOURCE_TYPES: tuple[type[Source], ...] = (ToxIni, SetupCfg, LegacyToml) +SOURCE_TYPES: tuple[type[Source], ...] = ( + ToxIni, + SetupCfg, + LegacyToml, + TomlPyProject, + TomlTox, +) 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: break else: # if not set use where we find pyproject.toml in the tree or cwd empty = root_dir - logging.warning("No %s found, assuming empty tox.ini at %s", " or ".join(i.FILENAME for i in SOURCE_TYPES), empty) + names = " or ".join({i.FILENAME: None for i in SOURCE_TYPES}) + logging.warning("No %s found, assuming empty tox.ini at %s", names, empty) return ToxIni(empty / "tox.ini", content="") diff --git a/src/tox/config/source/ini.py b/src/tox/config/source/ini.py index 0d4f98402..90dd40f53 100644 --- a/src/tox/config/source/ini.py +++ b/src/tox/config/source/ini.py @@ -107,9 +107,6 @@ def _discover_from_section(self, section: IniSection, known_factors: set[str]) - if set(env.split("-")) - known_factors: yield env - def __repr__(self) -> str: - return f"{type(self).__name__}(path={self.path})" - __all__ = [ "IniSource", diff --git a/src/tox/config/source/toml_pyproject.py b/src/tox/config/source/toml_pyproject.py new file mode 100644 index 000000000..ddf5d6688 --- /dev/null +++ b/src/tox/config/source/toml_pyproject.py @@ -0,0 +1,126 @@ +"""Load from a pyproject.toml file, native format.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any, Final, Iterator, Mapping, cast + +from tox.config.loader.section import Section +from tox.config.loader.toml import TomlLoader +from tox.report import HandledError + +from .api import Source + +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + import tomllib +else: # pragma: no cover (py311+) + import tomli as tomllib + +if TYPE_CHECKING: + from collections.abc import Iterable + from pathlib import Path + + from tox.config.loader.api import Loader, OverrideMap + from tox.config.sets import CoreConfigSet + + +class TomlSection(Section): + SEP: str = "." + PREFIX: tuple[str, ...] + ENV: Final[str] = "env" + RUN_ENV_BASE: Final[str] = "env_run_base" + PKG_ENV_BASE: Final[str] = "env_pkg_base" + + @classmethod + def test_env(cls, name: str) -> TomlSection: + return cls(cls.env_prefix(), name) + + @classmethod + def env_prefix(cls) -> str: + return cls.SEP.join((*cls.PREFIX, cls.ENV)) + + @classmethod + def package_env_base(cls) -> str: + return cls.SEP.join((*cls.PREFIX, cls.PKG_ENV_BASE)) + + @classmethod + def run_env_base(cls) -> str: + return cls.SEP.join((*cls.PREFIX, cls.RUN_ENV_BASE)) + + @property + def keys(self) -> Iterable[str]: + return self.key.split(self.SEP) if self.key else [] + + +class TomlPyProjectSection(TomlSection): + PREFIX = ("tool", "tox") + + +class TomlPyProject(Source): + """Configuration sourced from a pyproject.toml files.""" + + FILENAME = "pyproject.toml" + _Section: type[TomlSection] = TomlPyProjectSection + + def __init__(self, path: Path) -> None: + if path.name != self.FILENAME or not path.exists(): + raise ValueError + with path.open("rb") as file_handler: + toml_content = tomllib.load(file_handler) + try: + content: Mapping[str, Any] = toml_content + for key in self._Section.PREFIX: + content = content[key] + self._content = content + self._post_validate() + except KeyError as exc: + raise ValueError(path) from exc + super().__init__(path) + + def _post_validate(self) -> None: + if "legacy_tox_ini" in self._content: + msg = "legacy_tox_ini" + raise KeyError(msg) + + def get_core_section(self) -> Section: + return self._Section(prefix=None, name="") + + def transform_section(self, section: Section) -> Section: + return self._Section(section.prefix, section.name) + + def get_loader(self, section: Section, override_map: OverrideMap) -> Loader[Any] | None: + current = self._content + sec = cast(TomlSection, section) + for key in sec.keys: + if key in current: + current = current[key] + else: + return None + if not isinstance(current, Mapping): + msg = f"{sec.key} must be a mapping, is {current.__class__.__name__!r}" + raise HandledError(msg) + return TomlLoader( + section=section, + overrides=override_map.get(section.key, []), + content=current, + unused_exclude={sec.ENV, sec.RUN_ENV_BASE, sec.PKG_ENV_BASE} if section.prefix is None else set(), + ) + + def envs(self, core_conf: CoreConfigSet) -> Iterator[str]: + yield from core_conf["env_list"] + yield from [i.key for i in self.sections()] + + def sections(self) -> Iterator[Section]: + for env_name in self._content.get(self._Section.ENV, {}): + yield self._Section.from_key(env_name) + + def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: # noqa: ARG002 + yield from [self._Section.from_key(b) for b in base] + + def get_tox_env_section(self, item: str) -> tuple[Section, list[str], list[str]]: + return self._Section.test_env(item), [self._Section.run_env_base()], [self._Section.package_env_base()] + + +__all__ = [ + "TomlPyProject", +] diff --git a/src/tox/config/source/toml_tox.py b/src/tox/config/source/toml_tox.py new file mode 100644 index 000000000..ea0d49cd8 --- /dev/null +++ b/src/tox/config/source/toml_tox.py @@ -0,0 +1,23 @@ +"""Load from a tox.toml file.""" + +from __future__ import annotations + +from .toml_pyproject import TomlPyProject, TomlSection + + +class TomlToxSection(TomlSection): + PREFIX = () + + +class TomlTox(TomlPyProject): + """Configuration sourced from a pyproject.toml files.""" + + FILENAME = "tox.toml" + _Section = TomlToxSection + + def _post_validate(self) -> None: ... + + +__all__ = [ + "TomlTox", +] diff --git a/tests/config/loader/test_toml_loader.py b/tests/config/loader/test_toml_loader.py new file mode 100644 index 000000000..159c4b709 --- /dev/null +++ b/tests/config/loader/test_toml_loader.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional, Set, TypeVar + +import pytest + +from tox.config.loader.api import ConfigLoadArgs +from tox.config.loader.toml import TomlLoader +from tox.config.source.toml_pyproject import TomlPyProjectSection +from tox.config.types import Command, EnvList + + +def test_toml_loader_load_raw() -> None: + loader = TomlLoader(TomlPyProjectSection.from_key("tox.env.A"), [], {"a": 1, "c": False}, set()) + assert loader.load_raw("a", None, "A") == 1 + + +def test_toml_loader_load_repr() -> None: + loader = TomlLoader(TomlPyProjectSection.from_key("tox.env.A"), [], {"a": 1}, set()) + assert repr(loader) == "TomlLoader(env.A, {'a': 1})" + + +def test_toml_loader_found_keys() -> None: + loader = TomlLoader(TomlPyProjectSection.from_key("tox.env.A"), [], {"a": 1, "c": False}, set()) + assert loader.found_keys() == {"a", "c"} + + +def factory_na(obj: object) -> None: + raise NotImplementedError + + +V = TypeVar("V") + + +def perform_load(value: Any, of_type: type[V]) -> V: + env_name, key = "A", "k" + loader = TomlLoader(TomlPyProjectSection.from_key(f"tox.env.{env_name}"), [], {key: value}, set()) + args = ConfigLoadArgs(None, env_name, env_name) + return loader.load(key, of_type, factory_na, None, args) # type: ignore[arg-type] + + +def test_toml_loader_str_ok() -> None: + assert perform_load("s", str) == "s" + + +def test_toml_loader_str_nok() -> None: + with pytest.raises(TypeError, match="1 is not of type 'str'"): + perform_load(1, str) + + +def test_toml_loader_bool_ok() -> None: + assert perform_load(True, bool) is True + + +def test_toml_loader_bool_nok() -> None: + with pytest.raises(TypeError, match="'true' is not of type 'bool'"): + perform_load("true", bool) + + +def test_toml_loader_list_ok() -> None: + assert perform_load(["a"], List[str]) == ["a"] + + +def test_toml_loader_list_nok() -> None: + with pytest.raises(TypeError, match="{} is not list"): + perform_load({}, List[str]) + + +def test_toml_loader_list_nok_element() -> None: + with pytest.raises(TypeError, match="2 is not of type 'str'"): + perform_load(["a", 2], List[str]) + + +def test_toml_loader_set_ok() -> None: + assert perform_load({"a"}, Set[str]) == {"a"} + + +def test_toml_loader_set_nok() -> None: + with pytest.raises(TypeError, match="{} is not set"): + perform_load({}, Set[str]) + + +def test_toml_loader_set_nok_element() -> None: + with pytest.raises(TypeError, match="2 is not of type 'str'"): + perform_load({"a", 2}, Set[str]) + + +def test_toml_loader_dict_ok() -> None: + assert perform_load({"a": "1"}, Dict[str, str]) == {"a": "1"} + + +def test_toml_loader_dict_nok() -> None: + with pytest.raises(TypeError, match="{'a'} is not dictionary"): + perform_load({"a"}, Dict[str, str]) + + +def test_toml_loader_dict_nok_key() -> None: + with pytest.raises(TypeError, match="1 is not of type 'str'"): + perform_load({"a": 1, 1: "2"}, Dict[str, int]) + + +def test_toml_loader_dict_nok_value() -> None: + with pytest.raises(TypeError, match="'2' is not of type 'int'"): + perform_load({"a": 1, "b": "2"}, Dict[str, int]) + + +def test_toml_loader_path_ok() -> None: + assert perform_load("/w", Path) == Path("/w") + + +def test_toml_loader_path_nok() -> None: + with pytest.raises(TypeError, match="1 is not of type 'str'"): + perform_load(1, Path) + + +def test_toml_loader_command_ok() -> None: + commands = perform_load([["a", "b"], ["c"]], List[Command]) + assert isinstance(commands, list) + assert len(commands) == 2 + assert all(isinstance(i, Command) for i in commands) + + assert commands[0].args == ["a", "b"] + assert commands[1].args == ["c"] + + +def test_toml_loader_command_nok() -> None: + with pytest.raises(TypeError, match="1 is not of type 'str'"): + perform_load([["a", 1]], List[Command]) + + +def test_toml_loader_env_list_ok() -> None: + res = perform_load(["a", "b"], EnvList) + assert isinstance(res, EnvList) + assert list(res) == ["a", "b"] + + +def test_toml_loader_env_list_nok() -> None: + with pytest.raises(TypeError, match="1 is not of type 'str'"): + perform_load(["a", 1], EnvList) + + +def test_toml_loader_list_optional_ok() -> None: + assert perform_load(["a", None], List[Optional[str]]) == ["a", None] + + +def test_toml_loader_list_optional_nok() -> None: + with pytest.raises(TypeError, match="1 is not union of str, NoneType"): + perform_load(["a", None, 1], List[Optional[str]]) + + +def test_toml_loader_list_literal_ok() -> None: + assert perform_load(["a", "b"], List[Literal["a", "b"]]) == ["a", "b"] + + +def test_toml_loader_list_literal_nok() -> None: + with pytest.raises(TypeError, match="'c' is not one of literal 'a','b'"): + perform_load(["a", "c"], List[Literal["a", "b"]]) diff --git a/tests/config/source/test_discover.py b/tests/config/source/test_discover.py index d16927fea..86370e5ce 100644 --- a/tests/config/source/test_discover.py +++ b/tests/config/source/test_discover.py @@ -10,7 +10,7 @@ def out_no_src(path: Path) -> str: return ( - f"ROOT: No tox.ini or setup.cfg or pyproject.toml found, assuming empty tox.ini at {path}\n" + f"ROOT: No tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at {path}\n" f"default environments:\npy -> [no description]\n" ) diff --git a/tests/config/source/test_toml_pyproject.py b/tests/config/source/test_toml_pyproject.py new file mode 100644 index 000000000..f3ec1552f --- /dev/null +++ b/tests/config/source/test_toml_pyproject.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tox.pytest import ToxProjectCreator + + +def test_config_in_toml_core(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "pyproject.toml": """ + [tool.tox] + env_list = [ "A", "B"] + + [tool.tox.env_run_base] + description = "Do magical things" + commands = [ + ["python", "--version"], + ["python", "-c", "import sys; print(sys.executable)"] + ] + """ + }) + + outcome = project.run("c", "--core") + outcome.assert_success() + assert "# Exception: " not in outcome.out, outcome.out + assert "# !!! unused: " not in outcome.out, outcome.out + + +def test_config_in_toml_non_default(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "pyproject.toml": """ + [tool.tox.env.C] + description = "Do magical things in C" + commands = [ + ["python", "--version"] + ] + """ + }) + + outcome = project.run("c", "-e", "C", "--core") + outcome.assert_success() + assert "# Exception: " not in outcome.out, outcome.out + assert "# !!! unused: " not in outcome.out, outcome.out + + +def test_config_in_toml_extra(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "pyproject.toml": """ + [tool.tox.env_run_base] + description = "Do magical things" + commands = [ + ["python", "--version"] + ] + """ + }) + + outcome = project.run("c", "-e", ".".join(str(i) for i in sys.version_info[0:2])) + outcome.assert_success() + assert "# Exception: " not in outcome.out, outcome.out + assert "# !!! unused: " not in outcome.out, outcome.out diff --git a/tests/config/source/test_toml_tox.py b/tests/config/source/test_toml_tox.py new file mode 100644 index 000000000..c51fd8d5f --- /dev/null +++ b/tests/config/source/test_toml_tox.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tox.pytest import ToxProjectCreator + + +def test_config_in_toml_core(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "tox.toml": """ + env_list = [ "A", "B"] + + [env_run_base] + description = "Do magical things" + commands = [ + ["python", "--version"], + ["python", "-c", "import sys; print(sys.executable)"] + ] + """ + }) + + outcome = project.run("c", "--core") + outcome.assert_success() + assert "# Exception: " not in outcome.out, outcome.out + assert "# !!! unused: " not in outcome.out, outcome.out + + +def test_config_in_toml_non_default(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "tox.toml": """ + [env.C] + description = "Do magical things in C" + commands = [ + ["python", "--version"] + ] + """ + }) + + outcome = project.run("c", "-e", "C", "--core") + outcome.assert_success() + assert "# Exception: " not in outcome.out, outcome.out + assert "# !!! unused: " not in outcome.out, outcome.out + + +def test_config_in_toml_extra(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "tox.toml": """ + [env_run_base] + description = "Do magical things" + commands = [ + ["python", "--version"] + ] + """ + }) + + outcome = project.run("c", "-e", ".".join(str(i) for i in sys.version_info[0:2])) + outcome.assert_success() + assert "# Exception: " not in outcome.out, outcome.out + assert "# !!! unused: " not in outcome.out, outcome.out + + +def test_config_in_toml_replace(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.toml": '[env_run_base]\ndescription = "Magic in {env_name}"'}) + outcome = project.run("c", "-k", "commands") + outcome.assert_success() diff --git a/tests/session/cmd/test_legacy.py b/tests/session/cmd/test_legacy.py index 508e4ec9c..0d04dc6a1 100644 --- a/tests/session/cmd/test_legacy.py +++ b/tests/session/cmd/test_legacy.py @@ -65,7 +65,9 @@ def test_legacy_list_env_with_no_tox_file(tox_project: ToxProjectCreator) -> Non project = tox_project({}) outcome = project.run("le", "-l") outcome.assert_success() - out = f"ROOT: No tox.ini or setup.cfg or pyproject.toml found, assuming empty tox.ini at {project.path}\n" + out = ( + f"ROOT: No tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at {project.path}\n" + ) assert not outcome.err assert outcome.out == out diff --git a/tox.ini b/tox.ini index 60bd4e6c0..da1490f79 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ env_list = skip_missing_interpreters = true [testenv] -description = run the tests with pytest under {envname} +description = run the tests with pytest under {env_name} package = wheel wheel_build_env = .pkg extras = @@ -25,29 +25,28 @@ pass_env = PYTEST_* SSL_CERT_FILE set_env = - COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}} - COVERAGE_PROCESS_START = {toxinidir}{/}pyproject.toml + COVERAGE_FILE = {env:COVERAGE_FILE:{work_dir}{/}.coverage.{env_name}} + COVERAGE_PROCESS_START = {tox_root}{/}pyproject.toml commands = pytest {posargs: \ - --junitxml {toxworkdir}{/}junit.{envname}.xml --cov {envsitepackagesdir}{/}tox --cov {toxinidir}{/}tests \ - --cov-config={toxinidir}{/}pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \ - --cov-report html:{envtmpdir}{/}htmlcov \ - --cov-report xml:{toxworkdir}{/}coverage.{envname}.xml \ + --junitxml {work_dir}{/}junit.{env_name}.xml --cov {env_site_packages_dir}{/}tox --cov {tox_root}{/}tests \ + --cov-config={tox_root}{/}pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \ + --cov-report html:{env_tmp_dir}{/}htmlcov \ + --cov-report xml:{work_dir}{/}coverage.{env_name}.xml \ -n={env:PYTEST_XDIST_AUTO_NUM_WORKERS:auto} \ - tests --durations 5 --run-integration} - diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}{/}coverage.{envname}.xml + tests --durations 15 --run-integration} + diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {work_dir}{/}coverage.{env_name}.xml [testenv:fix] description = format the code base to adhere to our styles, and complain about what we cannot do automatically skip_install = true deps = - pre-commit-uv>=4.1 + pre-commit-uv>=4.1.3 pass_env = - {[testenv]passenv} + {[testenv]pass_env} PROGRAMDATA commands = pre-commit run --all-files --show-diff-on-failure {posargs} - python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")' [testenv:type] description = run type check on code base @@ -64,9 +63,9 @@ description = build documentation extras = docs commands = - {posargs: sphinx-build -d "{envtmpdir}{/}doctree" docs "{toxworkdir}{/}docs_out" --color -b linkcheck} - sphinx-build -d "{envtmpdir}{/}doctree" docs "{toxworkdir}{/}docs_out" --color -b html -W - python -c 'print(r"documentation available under file://{toxworkdir}{/}docs_out{/}index.html")' + {posargs: sphinx-build -d "{env_tmp_dir}{/}docs_tree" docs "{work_dir}{/}docs_out" --color -b linkcheck} + sphinx-build -d "{env_tmp_dir}{/}docs_tree" docs "{work_dir}{/}docs_out" --color -b html -W + python -c 'print(r"documentation available under file://{work_dir}{/}docs_out{/}index.html")' [testenv:pkg_meta] description = check that the long description is valid @@ -74,21 +73,21 @@ skip_install = true deps = check-wheel-contents>=0.6 twine>=5.1.1 - uv>=0.4.10 + uv>=0.4.17 commands = - uv build --sdist --wheel --out-dir {envtmpdir} . - twine check {envtmpdir}{/}* - check-wheel-contents --no-config {envtmpdir} + uv build --sdist --wheel --out-dir {env_tmp_dir} . + twine check {env_tmp_dir}{/}* + check-wheel-contents --no-config {env_tmp_dir} [testenv:release] -description = do a release, required posarg of the version number +description = do a release, required posargs of the version number skip_install = true deps = gitpython>=3.1.43 packaging>=24.1 towncrier>=24.8 commands = - python {toxinidir}/tasks/release.py --version {posargs} + python {tox_root}/tasks/release.py --version {posargs} [testenv:dev] description = dev environment with all deps at {envdir} @@ -100,5 +99,5 @@ extras = testing commands = python -m pip list --format=columns - python -c "print(r'{envpython}')" + python -c 'print(r"{env_python}")' uv_seed = true From ee4543580855c47f8028863a58ed4563f1e0e676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 29 Sep 2024 20:46:08 -0700 Subject: [PATCH 2/3] Add API for substitution and refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bernát Gábor --- src/tox/config/loader/toml/__init__.py | 16 ++- src/tox/config/loader/toml/_replace.py | 54 ++++++++++ tests/config/source/test_toml_tox.py | 2 +- tox.toml | 131 +++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/tox/config/loader/toml/_replace.py create mode 100644 tox.toml diff --git a/src/tox/config/loader/toml/__init__.py b/src/tox/config/loader/toml/__init__.py index 173e8395d..e636bfdf6 100644 --- a/src/tox/config/loader/toml/__init__.py +++ b/src/tox/config/loader/toml/__init__.py @@ -3,13 +3,15 @@ from pathlib import Path from typing import TYPE_CHECKING, Dict, Iterator, List, Mapping, Set, TypeVar, cast -from tox.config.loader.api import Loader, Override +from tox.config.loader.api import ConfigLoadArgs, Loader, Override from tox.config.types import Command, EnvList from ._api import TomlTypes +from ._replace import unroll_refs_and_apply_substitutions from ._validate import validate if TYPE_CHECKING: + from tox.config.loader.convert import Factory from tox.config.loader.section import Section from tox.config.main import Config @@ -37,6 +39,18 @@ def __repr__(self) -> str: def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> TomlTypes: # noqa: ARG002 return self.content[key] + def build( # noqa: PLR0913 + self, + key: str, # noqa: ARG002 + of_type: type[_T], + factory: Factory[_T], + conf: Config | None, + raw: TomlTypes, + args: ConfigLoadArgs, + ) -> _T: + raw = unroll_refs_and_apply_substitutions(conf=conf, loader=self, value=raw, args=args) + return self.to(raw, of_type, factory) + def found_keys(self) -> set[str]: return set(self.content.keys()) - self._unused_exclude diff --git a/src/tox/config/loader/toml/_replace.py b/src/tox/config/loader/toml/_replace.py new file mode 100644 index 000000000..f968d23b3 --- /dev/null +++ b/src/tox/config/loader/toml/_replace.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from tox.config.loader.api import ConfigLoadArgs + from tox.config.loader.toml import TomlLoader + from tox.config.main import Config + + from ._api import TomlTypes + +MAX_REPLACE_DEPTH: Final[int] = 100 + + +class MatchRecursionError(ValueError): + """Could not stabilize on replacement value.""" + + +def unroll_refs_and_apply_substitutions( + conf: Config | None, + loader: TomlLoader, + value: TomlTypes, + args: ConfigLoadArgs, + depth: int = 0, +) -> TomlTypes: + """Replace all active tokens within value according to the config.""" + if depth > MAX_REPLACE_DEPTH: + msg = f"Could not expand {value} after recursing {depth} frames" + raise MatchRecursionError(msg) + + if isinstance(value, str): + pass # apply string substitution here + elif isinstance(value, (int, float, bool)): + pass # no reference or substitution possible + elif isinstance(value, list): + # need to inspect every entry of the list to check for reference. + res_list: list[TomlTypes] = [] + for val in value: # apply replacement for every entry + got = unroll_refs_and_apply_substitutions(conf, loader, val, args, depth + 1) + res_list.append(got) + value = res_list + elif isinstance(value, dict): + # need to inspect every entry of the list to check for reference. + res_dict: dict[str, TomlTypes] = {} + for key, val in value.items(): # apply replacement for every entry + got = unroll_refs_and_apply_substitutions(conf, loader, val, args, depth + 1) + res_dict[key] = got + value = res_dict + return value + + +__all__ = [ + "unroll_refs_and_apply_substitutions", +] diff --git a/tests/config/source/test_toml_tox.py b/tests/config/source/test_toml_tox.py index c51fd8d5f..742393df2 100644 --- a/tests/config/source/test_toml_tox.py +++ b/tests/config/source/test_toml_tox.py @@ -63,5 +63,5 @@ def test_config_in_toml_extra(tox_project: ToxProjectCreator) -> None: def test_config_in_toml_replace(tox_project: ToxProjectCreator) -> None: project = tox_project({"tox.toml": '[env_run_base]\ndescription = "Magic in {env_name}"'}) - outcome = project.run("c", "-k", "commands") + outcome = project.run("c", "-k", "description") outcome.assert_success() diff --git a/tox.toml b/tox.toml new file mode 100644 index 000000000..f44cca119 --- /dev/null +++ b/tox.toml @@ -0,0 +1,131 @@ +requires = ["tox>=4.19"] +env_list = ["fix", "3.13", "3.12", "3.11", "3.10", "3.9", "3.8", "cov", "type", "docs", "pkg_meta"] +skip_missing_interpreters = true + +[env_run_base] +description = "run the tests with pytest under {env_name}" +package = "wheel" +wheel_build_env = ".pkg" +extras = ["testing"] +pass_env = ["PYTEST_*", "SSL_CERT_FILE"] +set_env.COVERAGE_FILE = { type = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" } +set_env.COVERAGE_FILECOVERAGE_PROCESS_START = "{tox_root}{/}pyproject.toml" +commands = [ + [ + "pytest", + { type = "posargs", default = [ + "--junitxml", + "{work_dir}{/}junit.{env_name}.xml", + "--cov", + "{env_site_packages_dir}{/}tox", + "--cov", + "{tox_root}{/}tests", + "--cov-config={tox_root}{/}pyproject.toml", + "-no-cov-on-fail", + "--cov-report", + "term-missing:skip-covered", + "--cov-context=test", + "--cov-report", + "html:{env_tmp_dir}{/}htmlcov", + "--cov-report", + "xml:{work_dir}{/}coverage.{env_name}.xml", + "-n", + { type = "env", name = "PYTEST_XDIST_AUTO_NUM_WORKERS", default = "auto" }, + "tests", + "--durations", + "15", + "--run-integration", + ] }, + ], + [ + "diff-cover", + "--compare-branch", + { type = "env", name = "DIFF_AGAINST", default = "origin/main" }, + "{work_dir}{/}coverage.{env_name}.xml", + ], +] + +[env.fix] +description = "format the code base to adhere to our styles, and complain about what we cannot do automatically" +skip_install = true +deps = ["pre-commit-uv>=4.1.3"] +pass_env = [{ type = "ref", of = ["env_run_base", "pass_env"] }, "PROGRAMDATA"] +commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure", { type = "posargs" }]] + +[env.type] +description = "run type check on code base" +deps = ["mypy==1.11.2", "types-cachetools>=5.5.0.20240820", "types-chardet>=5.0.4.6"] +commands = [["mypy", "src/tox"], ["mypy", "tests"]] + +[env.docs] +description = "build documentation" +extras = ["docs"] +commands = [ + { type = "posargs", default = [ + "sphinx-build", + "-d", + "{env_tmp_dir}{/}docs_tree", + "docs", + "{work_dir}{/}docs_out", + "--color", + "-b", + "linkcheck", + ] }, + [ + "sphinx-build", + "-d", + "{env_tmp_dir}{/}docs_tree", + "docs", + "{work_dir}{/}docs_out", + "--color", + "-b", + "html", + "-W", + ], + [ + "python", + "-c", + 'print(r"documentation available under file://{work_dir}{/}docs_out{/}index.html")', + ], +] + + +[env.pkg_meta] +description = "check that the long description is valid" +skip_install = true +deps = ["check-wheel-contents>=0.6", "twine>=5.1.1", "uv>=0.4.17"] +commands = [ + [ + "uv", + "build", + "--sdist", + "--wheel", + "--out-dir", + "{env_tmp_dir}", + ".", + ], + [ + "twine", + "check", + "{env_tmp_dir}{/}*", + ], + [ + "check-wheel-contents", + "--no-config", + "{env_tmp_dir}", + ], +] + +[env.release] +description = "do a release, required posargs of the version number" +skip_install = true +deps = ["gitpython>=3.1.43", "packaging>=24.1", "towncrier>=24.8"] +commands = [["python", "{tox_root}/tasks/release.py", "--version", "{posargs}"]] + +[env.dev] +description = "dev environment with all deps at {envdir}" +package = "editable" +deps = { type = "ref", of = ["env", "release", "deps"] } +extras = ["docs", "testing"] +commands = [["python", "-m", "pip", "list", "--format=columns"], ["python", "-c", 'print(r"{env_python}")']] +uv_seed = true From 42b1a425fa951ae12b781c84eded9f17526f6ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 30 Sep 2024 12:52:01 -0700 Subject: [PATCH 3/3] Implement string replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bernát Gábor --- docs/config.rst | 706 +++++++++++++----- docs/installation.rst | 47 +- docs/user_guide.rst | 412 +++++----- src/tox/config/loader/ini/__init__.py | 6 +- src/tox/config/loader/ini/replace.py | 382 ++-------- src/tox/config/loader/replacer.py | 297 ++++++++ src/tox/config/loader/stringify.py | 4 +- src/tox/config/loader/toml/__init__.py | 24 +- src/tox/config/loader/toml/_replace.py | 177 +++-- src/tox/config/loader/toml/_validate.py | 8 - src/tox/config/of_type.py | 16 +- src/tox/config/set_env.py | 7 +- src/tox/config/sets.py | 5 +- src/tox/config/source/toml_pyproject.py | 34 +- src/tox/config/source/toml_tox.py | 2 - .../loader/{ini/replace => }/conftest.py | 0 .../ini/replace/test_replace_env_var.py | 2 +- .../ini/replace/test_replace_os_pathsep.py | 2 +- .../loader/ini/replace/test_replace_os_sep.py | 2 +- .../ini/replace/test_replace_posargs.py | 2 +- .../ini/replace/test_replace_tox_env.py | 4 +- .../loader/ini/replace/test_replace_tty.py | 2 +- .../loader/{ini/replace => }/test_replace.py | 4 +- tests/config/loader/test_toml_loader.py | 24 +- tests/config/source/test_toml_pyproject.py | 253 +++++++ tests/config/source/test_toml_tox.py | 52 +- tox.ini | 2 +- tox.toml | 18 +- 28 files changed, 1669 insertions(+), 825 deletions(-) create mode 100644 src/tox/config/loader/replacer.py rename tests/config/loader/{ini/replace => }/conftest.py (100%) rename tests/config/loader/{ini/replace => }/test_replace.py (95%) diff --git a/docs/config.rst b/docs/config.rst index ecbbd2d92..33c3698dc 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -9,33 +9,47 @@ be set once and used for all tox environments, while environment options are app Discovery and file types ------------------------ -Out of box tox supports three configuration locations prioritized in the following order: +With regards to the configuration format, at the moment we support the following formats: -1. ``tox.ini``, -2. ``pyproject.toml``, -3. ``setup.cfg``. +- `INI `_. +- `TOML `_. -With regards to the configuration format, at the moment we only support *ini-style*. ``tox.ini`` and ``setup.cfg`` are -by nature such files, while in ``pyproject.toml`` currently you can only inline the *ini-style* config. +Out of box tox supports five configuration locations prioritized in the following order: -Note that ``setup.cfg`` requires the content to be under the ``tox:tox`` and ``testenv`` sections and is otherwise -ignored. ``pyproject.toml`` on the other hand is in TOML format. However, one can inline the *ini-style* format under -the ``tool.tox.legacy_tox_ini`` key as a multi-line string. +1. ``tox.ini`` (INI), +2. ``setup.cfg`` (INI), +3. ``pyproject.toml`` with the ``tool.tox`` table, having ``legacy_tox_ini`` key (containing INI), +4. Native ``pyproject.toml`` under the ``tool.tox`` table (TOML), +5. ``tox.toml`` (TOML). + +Historically, the INI format was created first, and TOML was added in 2024. The TOML format generally is more robust, +however is less powerful and more verbose. You should use TOML unless you need some of the more advanced features +that TOML does not support (such as conditional factors, generative environments to name a few -- however, PRs to +add support for these in TOML is welcome). + +.. _tox-ini: ``tox.ini`` ~~~~~~~~~~~ -The core settings are under the ``tox`` section while the environment sections are under the ``testenv:{env_name}`` -section. All tox environments by default inherit setting from the ``testenv`` section. This means if tox needs an option -and is not available under ``testenv:{env_name}`` will first try to use the value from ``testenv``, before falling back -to the default value for that setting. For example: + +This configuration file uses: + +- ``tox`` section to host core configuration, +- ``testenv:{env_name}`` section to host environment configuration, +- ``testenv`` section as base configuration for run environments (fallback location for missing values for a test/run + environment), +- ``pkgenv`` section as base configuration for package environments (fallback location for missing values for a package + environment). + +For example: .. code-block:: ini [tox] - min_version = 4.0 + min_version = 4.20 env_list = - py310 - py39 + 3.13 + 3.12 type [testenv] @@ -48,18 +62,22 @@ to the default value for that setting. For example: ``setup.cfg`` ~~~~~~~~~~~~~ -The core settings are under the ``tox:tox`` section while the environment sections are under the ``testenv:{env_name}`` -section. All tox environments by default inherit setting from the ``testenv`` section. This means if tox needs an option -and is not available under ``testenv:{env_name}`` will first try to use the value from ``testenv``, before falling back -to the default value for that setting. For example: +This configuration file uses: + +- ``tox:tox`` section to host core configuration, +- ``testenv:{env_name}`` section to host environment configuration, +- ``testenv`` section as base configuration for run environments (fallback location for missing values for a test/run + environment), +- ``pkgenv`` section as base configuration for package environments (fallback location for missing values for a package + environment). .. code-block:: ini [tox:tox] min_version = 4.0 env_list = - py310 - py39 + 3.13 + 3.12 type [testenv] @@ -70,12 +88,10 @@ to the default value for that setting. For example: deps = mypy commands = mypy src -``pyproject.toml`` -~~~~~~~~~~~~~~~~~~ -You can inline a ``tox.ini`` style configuration under the ``tool.tox`` section and ``legacy_tox_ini`` key. - -Below you find the specification for the *ini-style* format, but you might want to skim some -examples first and use this page as a reference. +``pyproject.toml`` - INI +~~~~~~~~~~~~~~~~~~~~~~~~ +This configuration file is equivalent to :ref:`tox.ini ` format, with the difference that the text is stored +instead inside the ``pyproject.toml`` file under the ``tool.tox`` table and ``legacy_tox_ini`` key: .. code-block:: toml @@ -97,6 +113,58 @@ examples first and use this page as a reference. commands = mypy src """ + +.. _pyproject-toml-native: + +``pyproject.toml`` - native +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We support native TOML configuration via the ``pyproject.toml`` files ``tool.tox`` table. This configuration file uses: + +- ``tool.tox`` table to host core configuration, +- ``tool.tox.env.{env_name}`` table to host environment configuration, +- ``tool.tox.env_run_base`` table as base configuration for run environments (fallback location for missing values for + a test/run environment), +- ``tool.tox.env_pkg_base`` table as base configuration for package environments (fallback location for missing values + for a package environment). + +.. code-block:: toml + + [tool.tox] + requires = ["tox>=4.19"] + env_list = ["3.13", "3.12", "type"] + + [tool.tox.env_run_base] + description = "Run test under {base_python}" + commands = [["pytest"]] + + [tool.tox.env.type] + description = "run type check on code base" + deps = ["mypy==1.11.2", "types-cachetools>=5.5.0.20240820", "types-chardet>=5.0.4.6"] + commands = [["mypy", "src{/}tox"], ["mypy", "tests"]] + +``tox.toml`` +~~~~~~~~~~~~ + +This configuration file is equivalent to :ref:`pyproject.toml - native ` with the difference +that it lives in a separate dedicated files and accordingly the ``tool.tox`` sub-table is no longer required. + +For example: + +.. code-block:: toml + + requires = ["tox>=4.19"] + env_list = ["3.13", "3.12", "type"] + + [env_run_base] + description = "Run test under {base_python}" + commands = [["pytest"]] + + [env.type] + description = "run type check on code base" + deps = ["mypy==1.11.2", "types-cachetools>=5.5.0.20240820", "types-chardet>=5.0.4.6"] + commands = [["mypy", "src{/}tox"], ["mypy", "tests"]] + .. _conf-core: Core @@ -114,13 +182,24 @@ The following options are set in the ``[tox]`` section of ``tox.ini`` or the ``[ environment that does not have this issue, and run the tox command within that environment. See :ref:`provision_tox_env` for more details. - .. code-block:: ini + .. tab:: TOML + + .. code-block:: toml + + [tool.tox.pyproject] + requires = [ + "tox>=4", + "virtualenv>20.2", + ] + + .. tab:: INI + + .. code-block:: ini [tox] requires = tox>=4 virtualenv>20.2 - .. conf:: :keys: min_version, minversion :default: @@ -208,12 +287,21 @@ The following options are set in the ``[tox]`` section of ``tox.ini`` or the ``[ A mapping of label names to environments it applies too. For example: - .. code-block:: ini + .. tab:: TOML + + .. code-block:: toml + + [tool.pyproject] + labels = { test = ["3.13", "3.12"], static = ["ruff", "mypy"] } - [tox] - labels = - test = py310, py39 - static = flake8, mypy + .. tab:: INI + + .. code-block:: ini + + [tox] + labels = + test = 3.13, 3.12 + static = ruff, mypy .. conf:: :keys: on_platform @@ -230,7 +318,7 @@ Python language core options .. versionadded:: 3.1.0 - tox allows setting the Python version for an environment via the :ref:`basepython` setting. If that's not set tox + tox allows setting the Python version for an environment via the :ref:`base_python` setting. If that's not set tox can set a default value from the environment name (e.g. ``py310`` implies Python 3.10). Matching up the Python version with the environment name has became expected at this point, leading to surprises when some configs don't do so. To help with sanity of users, an error will be raised whenever the environment name version does not match @@ -245,7 +333,7 @@ Python language core options tox environment --------------- -The following options are set in the ``[testenv]`` or ``[testenv:*]`` sections of ``tox.ini`` or ``setup.cfg``. +These are configuration for the tox environments (either packaging or run type). Base options ~~~~~~~~~~~~ @@ -494,12 +582,23 @@ Base options A list of labels to apply for this environment. For example: - .. code-block:: ini + .. tab:: TOML + + .. code-block:: toml + + [tool.pyproject.env_run_base] + labels = ["test", "core"] + [tool.pyproject.env.flake8] + labels = ["mypy"] - [testenv] - labels = test, core - [testenv:flake8] - labels = mypy + .. tab:: INI + + .. code-block:: ini + + [testenv] + labels = test, core + [testenv:flake8] + labels = mypy Execute ~~~~~~~ @@ -736,8 +835,20 @@ Python run ``-c`` (followed by a file path). For example: + .. tab:: TOML - .. code-block:: ini + .. code-block:: toml + + [tool.pyproject.env_run_base] + deps = [ + "pytest>=8", + "-r requirements.txt", + "-c constraints.txt", + ] + + .. tab:: INI + + .. code-block:: ini [testenv] deps = @@ -1019,116 +1130,243 @@ changed via ``TOX_USER_CONFIG_FILE`` environment variable. Example configuration [tox] skip_missing_interpreters = true -Substitutions -------------- -Any ``key=value`` setting in an ini-file can make use of **value substitution** -through the ``{...}`` string-substitution pattern. +Set CLI flags via environment variables +--------------------------------------- +All configuration can be overridden via environment variables too, the naming convention here is ``TOX_