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.py b/src/tox/config/loader/toml.py new file mode 100644 index 0000000000..68fb31108f --- /dev/null +++ b/src/tox/config/loader/toml.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterator, + List, + Literal, + Mapping, + Set, + TypeVar, + Union, + cast, +) + +from tox.config.loader.api import Loader, Override +from tox.config.types import Command, EnvList +from tox.report import HandledError + +if TYPE_CHECKING: + from tox.config.loader.section import Section + from tox.config.main import Config + +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + from typing import TypeGuard +else: # pragma: no cover (py311+) + from typing_extensions import TypeGuard +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] + + +class TomlLoader(Loader[TomlTypes]): + """Load configuration from a pyproject.toml file.""" + + def __init__( + self, + section: Section, + overrides: list[Override], + content: Mapping[str, TomlTypes], + ) -> None: + if not isinstance(content, Mapping): + msg = f"tox.{section.key} must be a mapping" + raise HandledError(msg) + self.content = content + super().__init__(section, overrides) + + 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()) + + @staticmethod + def to_str(value: TomlTypes) -> str: + return _ensure_type_correct(value, str) # type: ignore[return-value] # no mypy support + + @staticmethod + def to_bool(value: TomlTypes) -> bool: + return _ensure_type_correct(value, bool) + + @staticmethod + def to_list(value: TomlTypes, of_type: type[Any]) -> Iterator[_T]: + of = List[of_type] # type: ignore[valid-type] # no mypy support + return iter(_ensure_type_correct(value, of)) # type: ignore[call-overload,no-any-return] + + @staticmethod + def to_set(value: TomlTypes, of_type: type[Any]) -> Iterator[_T]: + of = Set[of_type] # type: ignore[valid-type] # no mypy support + return iter(_ensure_type_correct(value, of)) # type: ignore[call-overload,no-any-return] + + @staticmethod + def to_dict(value: TomlTypes, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[_T, _T]]: + of = Mapping[of_type[0], of_type[1]] # type: ignore[valid-type] # no mypy support + return _ensure_type_correct(value, of).items() # type: ignore[type-abstract,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))) + + +_T = TypeVar("_T") + + +def _ensure_type_correct(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 not (isinstance(val, list) and all(_ensure_type_correct(v, entry_type) for v in val)): + msg = f"{val} is not list" + elif issubclass(of_type, Command): + # first we cast it to list then create commands, so for now just validate is a nested list + _ensure_type_correct(val, list[str]) + elif casting_to in {set, Set}: + entry_type = of_type.__args__[0] # type: ignore[attr-defined] + if not (isinstance(val, set) and all(_ensure_type_correct(v, entry_type) for v in val)): + msg = f"{val} 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 not ( + isinstance(val, dict) + and all( + _ensure_type_correct(dict_key, key_type) and _ensure_type_correct(dict_value, value_type) + for dict_key, dict_value in val.items() + ) + ): + msg = f"{val} 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: + _ensure_type_correct(val, arg) + break + except TypeError: + pass + else: + msg = f"{val} is not union of {args}" + elif casting_to in {Literal, type(Literal)}: + choice = of_type.__args__ # type: ignore[attr-defined] + if val not in choice: + msg = f"{val} is not one of literal {choice}" + elif not isinstance(val, of_type): + msg = f"{val} is not one of {of_type}" + if msg: + raise TypeError(msg) + return cast(_T, val) # type: ignore[return-value] # logic too complicated for mypy + + +__all__ = [ + "TomlLoader", +] diff --git a/src/tox/config/source/discover.py b/src/tox/config/source/discover.py index 7fcac23cd7..22010ab510 100644 --- a/src/tox/config/source/discover.py +++ b/src/tox/config/source/discover.py @@ -9,12 +9,18 @@ from .legacy_toml import LegacyToml from .setup_cfg import SetupCfg +from .toml import Toml 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, + Toml, +) def discover_source(config_file: Path | None, root_dir: Path | None) -> Source: @@ -79,7 +85,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/toml.py b/src/tox/config/source/toml.py new file mode 100644 index 0000000000..15fb268e22 --- /dev/null +++ b/src/tox/config/source/toml.py @@ -0,0 +1,107 @@ +"""Load.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any, Iterator, Mapping, cast + +from tox.config.loader.section import Section +from tox.config.loader.toml import TomlLoader + +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 + +TEST_ENV_PREFIX = "env" + + +class TomlSection(Section): + SEP = "." + + @classmethod + def test_env(cls, name: str) -> TomlSection: + return cls(f"tox{cls.SEP}{name}", name) + + @property + def is_test_env(self) -> bool: + return self.prefix == TEST_ENV_PREFIX + + @property + def keys(self) -> Iterable[str]: + return self.key.split(self.SEP) + + +class Toml(Source): + """Configuration sourced from a pyproject.toml files.""" + + FILENAME = "pyproject.toml" + + 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["tool"]["tox"] + if "legacy_tox_ini" in content: + msg = "legacy_tox_ini" + raise KeyError(msg) # noqa: TRY301 + self._content = content + except KeyError as exc: + raise ValueError(path) from exc + super().__init__(path) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.path!r})" + + def get_core_section(self) -> Section: # noqa: PLR6301 + return TomlSection(prefix=None, name="tox") + + def transform_section(self, section: Section) -> Section: # noqa: PLR6301 + return TomlSection(section.prefix, section.name) + + def get_loader(self, section: Section, override_map: OverrideMap) -> Loader[Any] | None: + current = self._content + for at, key in enumerate(cast(TomlSection, section).keys): + if at == 0: + if key != "tox": + msg = "Internal error, first key is not tox" + raise RuntimeError(msg) + elif key in current: + current = current[key] + else: + return None + return TomlLoader( + section=section, + overrides=override_map.get(section.key, []), + content=current, + ) + + 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("env", {}): + yield TomlSection.from_key(env_name) + + def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: # noqa: PLR6301, ARG002 + yield from [TomlSection.from_key(b) for b in base] + + def get_tox_env_section(self, item: str) -> tuple[Section, list[str], list[str]]: # noqa: PLR6301 + return TomlSection.test_env(item), ["tox.env_base"], ["tox.pkgenv"] + + +__all__ = [ + "Toml", +] diff --git a/tests/config/loader/toml/test_toml_loader.py b/tests/config/loader/toml/test_toml_loader.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/config/source/test_toml.py b/tests/config/source/test_toml.py new file mode 100644 index 0000000000..9bf12bd4f1 --- /dev/null +++ b/tests/config/source/test_toml.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tox.pytest import ToxProjectCreator + + +def test_conf_in_legacy_toml(tox_project: ToxProjectCreator) -> None: + project = tox_project({ + "pyproject.toml": """ + [tool.tox] + env_list = [ "A", "B"] + + [tool.tox.env_base] + description = "Do magical things" + commands = [ + ["python", "--version"], + ["python", "-c", "import sys; print(sys.executable)"] + ] + + [tool.tox.env.C] + description = "Do magical things in C" + commands = [ + ["python", "--version"] + ] + """ + }) + + outcome = project.run("c", "--core", "-k", "commands") + outcome.assert_success() + + outcome = project.run("c", "-e", "C,3.13") + outcome.assert_success() 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