Skip to content

Commit

Permalink
True TOML config support
Browse files Browse the repository at this point in the history
Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>
  • Loading branch information
gaborbernat committed Sep 28, 2024
1 parent f5eba31 commit fa2f898
Show file tree
Hide file tree
Showing 13 changed files with 635 additions and 41 deletions.
7 changes: 2 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/changelog/999.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Native TOML configuration support - by :user:`gaborbernat`.
21 changes: 11 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
]
Expand Down
153 changes: 153 additions & 0 deletions src/tox/config/loader/toml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from __future__ import annotations

import sys
from inspect import isclass
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

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],
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 _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 = Dict[of_type[0], of_type[1]] # type: ignore[valid-type] # no mypy support
return _ensure_type_correct(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)))


_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 isinstance(val, list):
for va in val:
_ensure_type_correct(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 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 isinstance(val, set):
for va in val:
_ensure_type_correct(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:
_ensure_type_correct(va, key_type)
for va in val.values():
_ensure_type_correct(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:
_ensure_type_correct(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__ = [
"TomlLoader",
]
13 changes: 11 additions & 2 deletions src/tox/config/source/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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="")


Expand Down
133 changes: 133 additions & 0 deletions src/tox/config/source/toml_pyproject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""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 is_test_env(self) -> bool:
return self.prefix == self.env_prefix()

@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 __repr__(self) -> str:
return f"{self.__class__.__name__}({self.path!r})"

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",
]
Loading

0 comments on commit fa2f898

Please sign in to comment.