diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6f87ee3c43..4252dbc2ec 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 0000000000..bd4aceb3ea --- /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 d0643293c1..4f9836d6ed 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 0000000000..54087dc61f --- /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 ._abc 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 0000000000..9a9596df9d --- /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 0000000000..5868251652 --- /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 ._abc 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 068e7c5c1d..b8b703e625 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 7fcac23cd7..60cd2a832e 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 0d4f98402a..90dd40f534 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 0000000000..ddf5d66883 --- /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 0000000000..ea0d49cd8c --- /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 0000000000..159c4b7093 --- /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 d16927feab..86370e5cec 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 0000000000..f3ec1552f0 --- /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 0000000000..153199417b --- /dev/null +++ b/tests/config/source/test_toml_tox.py @@ -0,0 +1,61 @@ +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 diff --git a/tests/session/cmd/test_legacy.py b/tests/session/cmd/test_legacy.py index 508e4ec9cd..0d04dc6a12 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 60bd4e6c0a..da1490f79c 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