-
-
Notifications
You must be signed in to change notification settings - Fork 523
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>
- Loading branch information
1 parent
f5eba31
commit fa2f898
Showing
13 changed files
with
635 additions
and
41 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Native TOML configuration support - by :user:`gaborbernat`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
] |
Oops, something went wrong.