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 30, 2024
1 parent f5eba31 commit 7cc25cf
Show file tree
Hide file tree
Showing 17 changed files with 664 additions and 44 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
81 changes: 81 additions & 0 deletions src/tox/config/loader/toml/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
17 changes: 17 additions & 0 deletions src/tox/config/loader/toml/_api.py
Original file line number Diff line number Diff line change
@@ -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",
]
83 changes: 83 additions & 0 deletions src/tox/config/loader/toml/_validate.py
Original file line number Diff line number Diff line change
@@ -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",
]
3 changes: 3 additions & 0 deletions src/tox/config/source/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
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
3 changes: 0 additions & 3 deletions src/tox/config/source/ini.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 7cc25cf

Please sign in to comment.