diff --git a/src/tox/config/loader/convert.py b/src/tox/config/loader/convert.py index a91dbc0c2..e584ae3a9 100644 --- a/src/tox/config/loader/convert.py +++ b/src/tox/config/loader/convert.py @@ -4,9 +4,11 @@ from abc import ABC, abstractmethod from collections import OrderedDict from inspect import isclass +from itertools import chain from pathlib import Path -from typing import Any, Callable, Dict, Generic, Iterator, List, Literal, Optional, Set, TypeVar, Union, cast +from typing import Any, Callable, Dict, Generic, Iterable, Iterator, List, Literal, Optional, Set, TypeVar, Union, cast +from tox.config.loader.ini.factor import find_factor_groups from tox.config.types import Command, EnvList _NO_MAPPING = object() @@ -16,6 +18,44 @@ Factory = Optional[Callable[[object], T]] # note the argument is anything, due e.g. memory loader can inject anything +class ConditionalValue(Generic[T]): + """Value with a condition.""" + + def __init__(self, value: T, condition: str | None) -> None: + self.value = value + self.condition = condition + self._groups = tuple(find_factor_groups(condition)) if condition is not None else () + + def matches(self, env_name: str) -> bool: + """Return whether the value matches the environment name.""" + if self.condition is None: + return True + + # Split env_name to factors. + env_factors = set(chain.from_iterable([(i for i, _ in a) for a in find_factor_groups(env_name)])) + + matches = [] + for group in self._groups: + group_matches = [] + for factor, negate in group: + group_matches.append((factor in env_factors) ^ negate) + matches.append(all(group_matches)) + return any(matches) + + +class ConditionalSetting(Generic[T]): + """Setting whose value depends on various conditions.""" + + def __init__(self, values: typing.Iterable[ConditionalValue[T]]) -> None: + self.values = tuple(values) + + def filter(self, env_name: str) -> Iterable[T]: + """Filter values for the environment.""" + for value in self.values: + if value.matches(env_name): + yield value.value + + class Convert(ABC, Generic[T]): """A class that converts a raw type to a given tox (python) type.""" diff --git a/src/tox/config/loader/ini/factor.py b/src/tox/config/loader/ini/factor.py index 1bb639bbf..e595c0d0c 100644 --- a/src/tox/config/loader/ini/factor.py +++ b/src/tox/config/loader/ini/factor.py @@ -60,7 +60,11 @@ def expand_factors(value: str) -> Iterator[tuple[list[list[tuple[str, bool]]] | def find_factor_groups(value: str) -> Iterator[list[tuple[str, bool]]]: - """Transform '{py,!pi}-{a,b},c' to [{'py', 'a'}, {'py', 'b'}, {'pi', 'a'}, {'pi', 'b'}, {'c'}].""" + """Transform '{py,!pi}-{a,b},c' to [[('py', False), ('a', False)], + [('py', False), ('b', False)], + [('pi', True), ('a', False)], + [('pi', False), ('b', True)], + [('c', False)]].""" for env in expand_env_with_negation(value): result = [name_with_negate(f) for f in env.split("-")] yield result diff --git a/src/tox/config/loader/memory.py b/src/tox/config/loader/memory.py index 80ad82202..d3a56f793 100644 --- a/src/tox/config/loader/memory.py +++ b/src/tox/config/loader/memory.py @@ -1,11 +1,11 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any, Iterator, Sequence from tox.config.types import Command, EnvList -from .api import Loader +from .api import Loader, Override from .section import Section from .str_convert import StrConvert @@ -14,9 +14,19 @@ class MemoryLoader(Loader[Any]): - def __init__(self, **kwargs: Any) -> None: - super().__init__(Section(prefix="", name=str(id(self))), []) - self.raw: dict[str, Any] = {**kwargs} + def __init__( + self, + raw: dict[str, Any], + *, + section: Section | None = None, + overrides: list[Override] | None = None, + ) -> None: + section = section or Section(prefix="", name=str(id(self))) + super().__init__(section, overrides or []) + self.raw = raw + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(section={self._section.key}, overrides={self.overrides!r})" def load_raw(self, key: Any, conf: Config | None, env_name: str | None) -> Any: # noqa: ARG002 return self.raw[key] @@ -62,4 +72,6 @@ def to_env_list(value: Any) -> EnvList: return value if isinstance(value, str): return StrConvert.to_env_list(value) + if isinstance(value, Sequence): + return EnvList(value) raise TypeError(value) diff --git a/src/tox/config/loader/section.py b/src/tox/config/loader/section.py index 24656011d..8335498b4 100644 --- a/src/tox/config/loader/section.py +++ b/src/tox/config/loader/section.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Generic, TypeVar if TYPE_CHECKING: import sys @@ -11,16 +12,20 @@ from typing_extensions import Self -class Section: # noqa: PLW1641 - """tox configuration section.""" +X = TypeVar("X") + + +class BaseSection(ABC, Generic[X]): + """Base class for tox configuration section.""" SEP = ":" #: string used to separate the prefix and the section in the key - def __init__(self, prefix: str | None, name: str) -> None: + def __init__(self, prefix: X, name: str) -> None: self._prefix = prefix self._name = name @classmethod + @abstractmethod def from_key(cls: type[Self], key: str) -> Self: """ Create a section from a section key. @@ -28,15 +33,9 @@ def from_key(cls: type[Self], key: str) -> Self: :param key: the section key :return: the constructed section """ - sep_at = key.find(cls.SEP) - if sep_at == -1: - prefix, name = None, key - else: - prefix, name = key[:sep_at], key[sep_at + 1 :] - return cls(prefix, name) @property - def prefix(self) -> str | None: + def prefix(self) -> X: """:return: the prefix of the section""" return self._prefix @@ -46,9 +45,9 @@ def name(self) -> str: return self._name @property + @abstractmethod def key(self) -> str: """:return: the section key""" - return self.SEP.join(i for i in (self._prefix, self._name) if i is not None) def __str__(self) -> str: return self.key @@ -63,6 +62,31 @@ def __eq__(self, other: object) -> bool: ) +# TODO: Merge this with IniSection? +class Section(BaseSection[str | None]): + """tox configuration section.""" + + @classmethod + def from_key(cls: type[Self], key: str) -> Self: + """ + Create a section from a section key. + + :param key: the section key + :return: the constructed section + """ + sep_at = key.find(cls.SEP) + if sep_at == -1: + prefix, name = None, key + else: + prefix, name = key[:sep_at], key[sep_at + 1 :] + return cls(prefix, name) + + @property + def key(self) -> str: + """:return: the section key""" + return self.SEP.join(i for i in (self._prefix, self._name) if i is not None) + + __all__ = [ "Section", ] diff --git a/src/tox/config/source/api.py b/src/tox/config/source/api.py index 068e7c5c1..1a5f817ce 100644 --- a/src/tox/config/source/api.py +++ b/src/tox/config/source/api.py @@ -14,6 +14,8 @@ from tox.config.sets import ConfigSet, CoreConfigSet +# TODO: Generic in Section class? +# TODO: Use BaseSection instead of Section? class Source(ABC): """Source is able to return a configuration value (for either the core or per environment source).""" diff --git a/src/tox/config/source/discover.py b/src/tox/config/source/discover.py index 7fcac23cd..657675235 100644 --- a/src/tox/config/source/discover.py +++ b/src/tox/config/source/discover.py @@ -9,12 +9,13 @@ from .legacy_toml import LegacyToml from .setup_cfg import SetupCfg +from .toml import PyProjectToml, ToxToml 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, ToxToml, PyProjectToml, SetupCfg, LegacyToml) def discover_source(config_file: Path | None, root_dir: Path | None) -> Source: diff --git a/src/tox/config/source/toml.py b/src/tox/config/source/toml.py new file mode 100644 index 000000000..72d7d3331 --- /dev/null +++ b/src/tox/config/source/toml.py @@ -0,0 +1,164 @@ +"""Support for TOML config sources. + +This is experimental API! Expect things to be broken. +""" + +from __future__ import annotations + +from collections import defaultdict +from itertools import chain +from typing import TYPE_CHECKING, Any, Iterable, Iterator + +import tomllib + +from tox.config.loader.ini.factor import find_envs +from tox.config.loader.memory import MemoryLoader + +from .api import Source +from .toml_section import BASE_TEST_ENV, CORE, PKG_ENV_PREFIX, TEST_ENV_PREFIX, TEST_ENV_ROOT, TomlSection + +if TYPE_CHECKING: + from pathlib import Path + + from tox.config.loader.api import OverrideMap + from tox.config.loader.section import Section + from tox.config.sets import ConfigSet + + +def _extract_section(raw: dict[str, Any], section: TomlSection) -> Any: + """Extract section from TOML decoded data.""" + result = raw + for key in chain(section.prefix, (section.name,)): + if key in result: + result = result[key] + else: + return None + return result + + +class TomlSource(Source): + """Configuration sourced from a toml file (such as tox.toml). + + This is experimental API! Expect things to be broken. + """ + + CORE_SECTION = CORE + ROOT_KEY: str | None = None + + def __init__(self, path: Path, content: str | None = None) -> None: + super().__init__(path) + if content is None: + if not path.exists(): + msg = f"Path {path} does not exist." + raise ValueError(msg) + content = path.read_text() + data = tomllib.loads(content) + if self.ROOT_KEY: + if self.ROOT_KEY not in data: + msg = f"Section {self.ROOT_KEY} not found in {path}." + raise ValueError(msg) + data = data[self.ROOT_KEY] + self._raw = data + self._section_mapping: defaultdict[str, list[str]] = defaultdict(list) + + def __repr__(self) -> str: + return f"{type(self).__name__}(path={self.path})" + + def transform_section(self, section: Section) -> Section: + return TomlSection(section.prefix, section.name) + + def get_loader(self, section: TomlSection, override_map: OverrideMap) -> MemoryLoader | None: + result = _extract_section(self._raw, section) + if result is None: + return None + + return MemoryLoader( + result, + section=section, + overrides=override_map.get(section.key, []), + ) + + def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: # noqa: PLR6301 + for a_base in base: + yield TomlSection(in_section.prefix, a_base) + + def sections(self) -> Iterator[TomlSection]: + # TODO: just return core section and any `tox.env.XXX` sections which exist directly. + for key in self._raw: + section = TomlSection.from_key(key) + yield section + if section == self.CORE_SECTION: + test_env_data = _extract_section(self._raw, TEST_ENV_ROOT) + for env_name in test_env_data or {}: + yield TomlSection(TEST_ENV_PREFIX, env_name) + + def envs(self, core_config: ConfigSet) -> Iterator[str]: + seen = set() + for name in self._discover_tox_envs(core_config): + if name not in seen: + seen.add(name) + yield name + + def _discover_tox_envs(self, core_config: ConfigSet) -> Iterator[str]: + def register_factors(envs: Iterable[str]) -> None: + known_factors.update(chain.from_iterable(e.split("-") for e in envs)) + + explicit = list(core_config["env_list"]) + yield from explicit + known_factors: set[str] = set() + register_factors(explicit) + + # discover all additional defined environments, including generative section headers + for section in self.sections(): + if section.is_test_env: + register_factors(section.names) + for name in section.names: + self._section_mapping[name].append(section.key) + yield name + # add all conditional markers that are not part of the explicitly defined sections + for section in self.sections(): + yield from self._discover_from_section(section, known_factors) + + def _discover_from_section(self, section: TomlSection, known_factors: set[str]) -> Iterator[str]: + section_data = _extract_section(self._raw, section) + for value in (section_data or {}).values(): + if isinstance(value, bool): + # It's not a value with env definition. + continue + # XXX: We munch the value to multiline string to parse it by the library utils. + merged_value = "\n".join(str(v) for v in tuple(value)) + for env in find_envs(merged_value): + if set(env.split("-")) - known_factors: + yield env + + def get_tox_env_section(self, item: str) -> tuple[TomlSection, list[str], list[str]]: # noqa: PLR6301 + return TomlSection.test_env(item), [BASE_TEST_ENV], [PKG_ENV_PREFIX] + + def get_core_section(self) -> TomlSection: + return self.CORE_SECTION + + +class ToxToml(TomlSource): + """Configuration sourced from a tox.toml file. + + This is experimental API! Expect things to be broken. + """ + + FILENAME = "tox.toml" + + +class PyProjectToml(TomlSource): + """Configuration sourced from a pyproject.toml file. + + This is experimental API! Expect things to be broken. + """ + + FILENAME = "pyproject.toml" + ROOT_KEY = "tool" + + def __init__(self, path: Path, content: str | None = None) -> None: + super().__init__(path, content) + core_data = _extract_section(self._raw, self.CORE_SECTION) + if core_data is not None and tuple(core_data.keys()) == ("legacy_tox_ini",): + msg = "pyproject.toml is in the legacy mode." + raise ValueError(msg) diff --git a/src/tox/config/source/toml_section.py b/src/tox/config/source/toml_section.py new file mode 100644 index 000000000..fcb18cac1 --- /dev/null +++ b/src/tox/config/source/toml_section.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from itertools import chain +from typing import Sequence + +from tox.config.loader.ini.factor import extend_factors +from tox.config.loader.section import BaseSection + +# TODO: Shouldn't this be loaded from config? +BASE_TEST_ENV = "testenv" +TEST_ENV_PREFIX = ("tox", "env") +# TODO: PKG_ENV_PREFIX? +PKG_ENV_PREFIX = "pkgenv" + + +# TODO: Duplicates IniSection API +class TomlSection(BaseSection[Sequence[str]]): + def __init__(self, prefix: Sequence[str] | None, name: str) -> None: + super().__init__(tuple(prefix or ()), name) + + @classmethod + def from_key(cls: type[TomlSection], key: str) -> TomlSection: + """ + Create a section from a section key. + + :param key: the section key + :return: the constructed section + """ + chunks = key.split(cls.SEP) + return cls(chunks[:-1], chunks[-1]) + + @property + def key(self) -> str: + """:return: the section key""" + return self.SEP.join(chain(self._prefix, (self._name,))) + + @classmethod + def test_env(cls, name: str) -> TomlSection: + return cls(TEST_ENV_PREFIX, name) + + @property + def is_test_env(self) -> bool: + return self.prefix == TEST_ENV_PREFIX and self.name != BASE_TEST_ENV + + @property + def names(self) -> list[str]: + return list(extend_factors(self.name)) + + +CORE = TomlSection(None, "tox") +TEST_ENV_ROOT = TomlSection(TEST_ENV_PREFIX[:-1], TEST_ENV_PREFIX[-1]) diff --git a/src/tox/provision.py b/src/tox/provision.py index 9e1e2bf8c..e19bf2187 100644 --- a/src/tox/provision.py +++ b/src/tox/provision.py @@ -91,12 +91,14 @@ def add_tox_requires_min_version(reqs: list[Requirement]) -> list[Requirement]: deps = ", ".join(f"{p}{'' if v is None else f' ({v})'}" for p, v in missing) loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) - base=[], # disable inheritance for provision environments - package="skip", # no packaging for this please - # use our own dependency specification - deps=PythonDeps("\n".join(str(r) for r in requires), root=state.conf.core["tox_root"]), - pass_env=["*"], # do not filter environment variables, will be handled by provisioned tox - recreate=state.conf.options.recreate and not state.conf.options.no_recreate_provision, + { + "base": [], # disable inheritance for provision environments + "package": "skip", # no packaging for this please + # use our own dependency specification + "deps": PythonDeps("\n".join(str(r) for r in requires), root=state.conf.core["tox_root"]), + "pass_env": ["*"], # do not filter environment variables, will be handled by provisioned tox + "recreate": state.conf.options.recreate and not state.conf.options.no_recreate_provision, + } ) provision_tox_env: str = state.conf.core["provision_tox_env"] state.conf.memory_seed_loaders[provision_tox_env].append(loader) diff --git a/src/tox/session/cmd/devenv.py b/src/tox/session/cmd/devenv.py index c4754e546..c5e11c257 100644 --- a/src/tox/session/cmd/devenv.py +++ b/src/tox/session/cmd/devenv.py @@ -35,8 +35,10 @@ def devenv(state: State) -> int: opt.skip_pkg_install = False # always install a package in this case opt.no_test = True # do not run the test phase loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) - usedevelop=True, # dev environments must be of type dev - env_dir=opt.devenv_path, # move it in source + { + "usedevelop": True, # dev environments must be of type dev + "env_dir": opt.devenv_path, # move it in source + } ) state.conf.memory_seed_loaders[next(iter(opt.env))].append(loader) diff --git a/src/tox/session/cmd/exec_.py b/src/tox/session/cmd/exec_.py index 4ce3e947e..3f94b53f7 100644 --- a/src/tox/session/cmd/exec_.py +++ b/src/tox/session/cmd/exec_.py @@ -34,9 +34,11 @@ def exec_(state: State) -> int: msg = f"exactly one target environment allowed in exec mode but found {', '.join(envs)}" raise HandledError(msg) loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) - commands_pre=[], - commands=[], - commands_post=[], + { + "commands_pre": [], + "commands": [], + "commands_post": [], + } ) conf = state.envs[envs[0]].conf conf.loaders.insert(0, loader) diff --git a/src/tox/session/cmd/legacy.py b/src/tox/session/cmd/legacy.py index a78d8bac7..05f122e2d 100644 --- a/src/tox/session/cmd/legacy.py +++ b/src/tox/session/cmd/legacy.py @@ -129,7 +129,7 @@ def _handle_legacy_only_flags(option: Parsed, envs: EnvSelector) -> None: # noq for env in envs.iter(only_active=True, package=False): env_conf = envs[env].conf if override: - env_conf.loaders.insert(0, MemoryLoader(**override)) + env_conf.loaders.insert(0, MemoryLoader(override)) if set_env: cast(SetEnv, env_conf["set_env"]).update(set_env, override=True) if forced: diff --git a/tests/config/cli/test_cli_ini.py b/tests/config/cli/test_cli_ini.py index 82fb7fc95..2e28ebb0b 100644 --- a/tests/config/cli/test_cli_ini.py +++ b/tests/config/cli/test_cli_ini.py @@ -145,10 +145,17 @@ def test_cli_ini_with_interpolated(tmp_path: Path, monkeypatch: MonkeyPatch) -> [ pytest.param("", "tox.ini", "[tox]", id="ini-dir"), pytest.param("tox.ini", "tox.ini", "[tox]", id="ini"), + pytest.param("", "tox.toml", "[tox]", id="toml-dir"), + pytest.param("tox.toml", "tox.toml", "[tox]", id="toml"), pytest.param("", "setup.cfg", "[tox:tox]", id="cfg-dir"), pytest.param("setup.cfg", "setup.cfg", "[tox:tox]", id="cfg"), - pytest.param("", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-dir"), - pytest.param("pyproject.toml", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml"), + pytest.param("", "pyproject.toml", "[tool.tox]", id="toml-dir"), + pytest.param("pyproject.toml", "pyproject.toml", "[tool.tox]", id="toml"), + # TODO: Fix this + # pytest.param("", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-legacy-dir"), + # pytest.param( + # "pyproject.toml", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-legacy" + # ), ], ) def test_conf_arg(tmp_path: Path, conf_arg: str, filename: str, content: str) -> None: diff --git a/tests/config/conftest.py b/tests/config/conftest.py index 8cdc9ce19..db0606a89 100644 --- a/tests/config/conftest.py +++ b/tests/config/conftest.py @@ -5,10 +5,17 @@ import pytest if TYPE_CHECKING: - from tests.conftest import ToxIniCreator + from tests.conftest import ToxIniCreator, ToxTomlCreator from tox.config.main import Config @pytest.fixture def empty_config(tox_ini_conf: ToxIniCreator) -> Config: + """Make and return an empty INI config file.""" return tox_ini_conf("") + + +@pytest.fixture +def empty_toml_config(tox_toml_conf: ToxTomlCreator) -> Config: + """Make and return an empty TOML config file.""" + return tox_toml_conf("") diff --git a/tests/config/loader/test_convert.py b/tests/config/loader/test_convert.py new file mode 100644 index 000000000..749708c8a --- /dev/null +++ b/tests/config/loader/test_convert.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Any, Iterable + +import pytest + +from tox.config.loader.convert import ConditionalSetting, ConditionalValue + + +@pytest.mark.parametrize( + ("condition", "env_name", "result"), + [ + (None, "a", True), + (None, "a-b", True), + ("a", "a", True), + ("!a", "a", False), + ("a", "b", False), + ("!a", "b", True), + ("a", "a-b", True), + ("!a", "a-b", False), + # or + ("a,b", "a", True), + ("a,b", "b", True), + ("a,b", "c", False), + ("a,b", "a-b", True), + ("!a,!b", "c", True), + # and + ("a-b", "a", False), + ("a-b", "c", False), + ("a-b", "a-b", True), + ("a-!b", "a-b", False), + ("!a-b", "a-b", False), + ], +) +def test_conditional_value_matches(condition: str, env_name: str, result: bool) -> None: + assert ConditionalValue(42, condition).matches(env_name) is result + + +@pytest.mark.parametrize( + ("values", "env_name", "result"), + [ + ([], "a", []), + ([ConditionalValue(42, None)], "a", [42]), + ([ConditionalValue(42, None)], "b", [42]), + ([ConditionalValue(42, "!a")], "a", []), + ([ConditionalValue(42, "!a")], "b", [42]), + ([ConditionalValue(42, "a"), ConditionalValue(43, "!a")], "a", [42]), + ([ConditionalValue(42, "a"), ConditionalValue(43, "!a")], "b", [43]), + ([ConditionalValue(42, "a"), ConditionalValue(43, "a")], "a", [42, 43]), + ], +) +def test_conditional_setting_filter(values: Iterable[ConditionalValue], env_name: str, result: list[Any]) -> None: + setting = ConditionalSetting(values) + assert list(setting.filter(env_name)) == result diff --git a/tests/config/loader/test_memory_loader.py b/tests/config/loader/test_memory_loader.py index 508566df1..230c36e9d 100644 --- a/tests/config/loader/test_memory_loader.py +++ b/tests/config/loader/test_memory_loader.py @@ -8,16 +8,18 @@ from tox.config.loader.api import ConfigLoadArgs, Override from tox.config.loader.memory import MemoryLoader +from tox.config.loader.section import Section from tox.config.types import Command, EnvList def test_memory_loader_repr() -> None: - loader = MemoryLoader(a=1) - assert repr(loader) == "MemoryLoader" + core = Section(None, "tox") + loader = MemoryLoader({"a": 1}, section=core) + assert repr(loader) == "MemoryLoader(section=tox, overrides={})" def test_memory_loader_override() -> None: - loader = MemoryLoader(a=1) + loader = MemoryLoader({"a": 1}) loader.overrides["a"] = [Override("a=2")] args = ConfigLoadArgs([], "name", None) loaded = loader.load("a", of_type=int, conf=None, factory=None, args=args) @@ -48,11 +50,12 @@ def test_memory_loader_override() -> None: (os.getcwd(), Path, Path.cwd()), # noqa: PTH109 ("pip list", Command, Command(["pip", "list"])), ("a\nb", EnvList, EnvList(["a", "b"])), + (["a", "b"], EnvList, EnvList(["a", "b"])), ("1", Optional[int], 1), ], ) def test_memory_loader(value: Any, of_type: type[Any], outcome: Any) -> None: - loader = MemoryLoader(a=value, kwargs={}) + loader = MemoryLoader({"a": value, "kwargs": {}}) args = ConfigLoadArgs([], "name", None) loaded = loader.load("a", of_type=of_type, conf=None, factory=None, args=args) assert loaded == outcome @@ -72,18 +75,18 @@ def test_memory_loader(value: Any, of_type: type[Any], outcome: Any) -> None: ], ) def test_memory_loader_fails_invalid(value: Any, of_type: type[Any], exception: Exception, msg: str) -> None: - loader = MemoryLoader(a=value, kwargs={}) + loader = MemoryLoader({"a": value, "kwargs": {}}) args = ConfigLoadArgs([], "name", None) with pytest.raises(exception, match=msg): # type: ignore[call-overload] loader.load("a", of_type=of_type, conf=None, factory=None, args=args) def test_memory_found_keys() -> None: - loader = MemoryLoader(a=1, c=2) + loader = MemoryLoader({"a": 1, "c": 2}) assert loader.found_keys() == {"a", "c"} def test_memory_loader_contains() -> None: - loader = MemoryLoader(a=1) + loader = MemoryLoader({"a": 1}) assert "a" in loader assert "b" not in loader diff --git a/tests/config/loader/toml/conftest.py b/tests/config/loader/toml/conftest.py new file mode 100644 index 000000000..2ed48da24 --- /dev/null +++ b/tests/config/loader/toml/conftest.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable + +import pytest +import tomllib + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def mk_toml_conf(tmp_path: Path) -> Callable[[str], dict[str, Any]]: + def _func(raw: str) -> dict[str, Any]: + filename = tmp_path / "demo.toml" + filename.write_bytes(raw.encode("utf-8")) # win32: avoid CR normalization - what you pass is what you get + with filename.open("rb") as file_handler: + return tomllib.load(file_handler) + + return _func diff --git a/tests/config/source/test_discover.py b/tests/config/source/test_discover.py index d16927fea..2c1fa78c7 100644 --- a/tests/config/source/test_discover.py +++ b/tests/config/source/test_discover.py @@ -9,8 +9,9 @@ def out_no_src(path: Path) -> str: + # TODO: Deduplicate file names. 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 tox.toml or pyproject.toml or setup.cfg or pyproject.toml found, assuming empty tox.ini at {path}\n" f"default environments:\npy -> [no description]\n" ) diff --git a/tests/config/source/test_source_toml.py b/tests/config/source/test_source_toml.py new file mode 100644 index 000000000..0ef498b9d --- /dev/null +++ b/tests/config/source/test_source_toml.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tox.config.sets import ConfigSet +from tox.config.source.toml import ToxToml +from tox.config.source.toml_section import TomlSection + +# from tox.config.source.toml import PyProjectToml + +if TYPE_CHECKING: + from pathlib import Path + + from tests.conftest import ToxTomlCreator + from tox.pytest import ToxProjectCreator + + +def test_conf_in_tox_toml(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.toml": "[tox]\nenv_list=['a', 'b']"}) + + outcome = project.run("l") + outcome.assert_success() + assert outcome.out == "default environments:\na -> [no description]\nb -> [no description]\n" + + +def test_source_toml_with_interpolated(tmp_path: Path) -> None: + loader = ToxToml(tmp_path, content="[tox]\na = '%(c)s'").get_loader(TomlSection((), "tox"), {}) + assert loader is not None + loader.load_raw("a", None, None) + + +def test_source_toml_ignore_non_testenv_sections(tmp_path: Path) -> None: + loader = ToxToml(tmp_path, content="['mypy-rest_framework.compat.*']") + res = list(loader.envs({"env_list": []})) # type: ignore[arg-type] + assert not res + + +def test_source_toml_ignore_invalid_factor_filters(tmp_path: Path) -> None: + loader = ToxToml(tmp_path, content="[a]\nb= 'if c: d'") + res = list(loader.envs({"env_list": []})) # type: ignore[arg-type] + assert not res + + +def test_source_toml_custom_non_testenv_sections(tox_toml_conf: ToxTomlCreator) -> None: + """Validate that a plugin can load section with custom prefix overlapping testenv name.""" + + class CustomConfigSet(ConfigSet): + def register_config(self) -> None: + self.add_config( + keys=["a"], + of_type=str, + default="", + desc="d", + ) + + config = tox_toml_conf("[tox.env.foo]\n[custom.foo]\na = 'b'") + known_envs = list(config._src.envs(config.core)) # noqa: SLF001 + assert known_envs + custom_section = config.get_section_config( + section=TomlSection(("custom",), "foo"), + base=[], + of_type=CustomConfigSet, + for_env=None, + ) + assert custom_section["a"] == "b" + + +def test_conf_in_pyproject_toml(tox_project: ToxProjectCreator) -> None: + project = tox_project({"pyproject.toml": "[tool.tox]\nenv_list=['a', 'b']"}) + + outcome = project.run("l") + outcome.assert_success() + assert outcome.out == "default environments:\na -> [no description]\nb -> [no description]\n" diff --git a/tests/config/source/test_toml_section.py b/tests/config/source/test_toml_section.py new file mode 100644 index 000000000..8171cd041 --- /dev/null +++ b/tests/config/source/test_toml_section.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import pytest + +from tox.config.source.toml_section import TomlSection + + +def test_toml_section_immutable_prefix() -> None: + assert TomlSection(["a"], "b").prefix == ("a",) + + +@pytest.mark.parametrize( + ("section", "key"), + [ + (TomlSection((), "a"), "a"), + (TomlSection(("a"), "b"), "a:b"), + (TomlSection(("a", "b"), "c"), "a:b:c"), + ], +) +def test_toml_section_key(section: TomlSection, key: str) -> None: + assert section.key == key + + +@pytest.mark.parametrize( + ("key", "section"), + [ + ("a", TomlSection((), "a")), + ("a:b", TomlSection(("a"), "b")), + ("a:b:c", TomlSection(("a", "b"), "c")), + ], +) +def test_toml_section_from_key(key: str, section: TomlSection) -> None: + assert section.from_key(key) == section + + +def test_toml_section_test_env() -> None: + assert TomlSection.test_env("example") == TomlSection(("tox", "env"), "example") + + +@pytest.mark.parametrize( + ("section", "is_test_env"), + [ + (TomlSection((), "a"), False), + (TomlSection(("tox"), "a"), False), + (TomlSection(("env",), "a"), False), + (TomlSection(("tox", "env"), "a"), True), + # The default testenv is not a testenv itself. + (TomlSection(("tox", "env"), "testenv"), False), + (TomlSection(("tox", "other"), "a"), False), + (TomlSection(("tox", "env", "other"), "a"), False), + ], +) +def test_toml_section_is_test_env(section: TomlSection, is_test_env: bool) -> None: + assert section.is_test_env == is_test_env diff --git a/tests/config/test_main.py b/tests/config/test_main.py index 853ef9bb4..5aa5897f5 100644 --- a/tests/config/test_main.py +++ b/tests/config/test_main.py @@ -13,7 +13,7 @@ from tox.tox_env.python.pip.req_file import PythonDeps if TYPE_CHECKING: - from tests.conftest import ToxIniCreator + from tests.conftest import ToxIniCreator, ToxTomlCreator from tox.config.main import Config from tox.pytest import ToxProjectCreator @@ -24,11 +24,22 @@ def test_empty_config_repr(empty_config: Config) -> None: assert "config_source=ToxIni" in text +def test_empty_toml_config_repr(empty_toml_config: Config) -> None: + text = repr(empty_toml_config) + assert str(empty_toml_config.core["tox_root"]) in text + assert "config_source=ToxToml" in text + + def test_empty_conf_tox_envs(empty_config: Config) -> None: tox_env_keys = list(empty_config) assert tox_env_keys == [] +def test_empty_toml_conf_tox_envs(empty_toml_config: Config) -> None: + tox_env_keys = list(empty_toml_config) + assert tox_env_keys == [] + + def test_empty_conf_get(empty_config: Config) -> None: result = empty_config.get_env("magic") assert isinstance(result, ConfigSet) @@ -36,6 +47,13 @@ def test_empty_conf_get(empty_config: Config) -> None: assert loaders == ["testenv"] +def test_empty_toml_conf_get(empty_toml_config: Config) -> None: + result = empty_toml_config.get_env("magic") + assert isinstance(result, ConfigSet) + loaders = result["base"] + assert loaders == ["testenv"] + + def test_config_some_envs(tox_ini_conf: ToxIniCreator) -> None: example = """ [tox] @@ -55,6 +73,27 @@ def test_config_some_envs(tox_ini_conf: ToxIniCreator) -> None: assert list(config_set) +def test_toml_config_some_envs(tox_toml_conf: ToxTomlCreator) -> None: + example = """ + [tox] + env_list = ['py38', 'py37'] + [tox.env.testenv] + deps = [ + '1', + 'other: 2', + ] + [tox.env.magic] + """ + config = tox_toml_conf(example) + tox_env_keys = list(config) + assert tox_env_keys == ["py38", "py37", "magic", "other"] + + config_set = config.get_env("py38") + assert repr(config_set) + assert isinstance(config_set, ConfigSet) + assert list(config_set) + + def test_config_overrides(tox_ini_conf: ToxIniCreator) -> None: conf = tox_ini_conf("[testenv]", override=[Override("testenv.c=ok")]).get_env("py") conf.add_config("c", of_type=str, default="d", desc="desc") @@ -63,7 +102,7 @@ def test_config_overrides(tox_ini_conf: ToxIniCreator) -> None: def test_config_override_wins_memory_loader(tox_ini_conf: ToxIniCreator) -> None: main_conf = tox_ini_conf("[testenv]", override=[Override("testenv.c=ok")]) - conf = main_conf.get_env("py", loaders=[MemoryLoader(c="something_else")]) + conf = main_conf.get_env("py", loaders=[MemoryLoader({"c": "something_else"})]) conf.add_config("c", of_type=str, default="d", desc="desc") assert conf["c"] == "ok" diff --git a/tests/config/test_sets.py b/tests/config/test_sets.py index 91aec92c1..5db2d7ee2 100644 --- a/tests/config/test_sets.py +++ b/tests/config/test_sets.py @@ -181,7 +181,7 @@ def test_do_not_allow_create_config_set(mocker: MockerFixture) -> None: def test_set_env_raises_on_non_str(mocker: MockerFixture) -> None: env_set = EnvConfigSet(mocker.create_autospec(Config), Section("a", "b"), "b") - env_set.loaders.insert(0, MemoryLoader(set_env=1)) + env_set.loaders.insert(0, MemoryLoader({"set_env": 1})) with pytest.raises(TypeError, match="1"): assert env_set["set_env"] diff --git a/tests/conftest.py b/tests/conftest.py index 7a12fbef2..96277dc87 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,6 +48,8 @@ def __call__(self, conf: str, override: Sequence[Override] | None = None) -> Con @pytest.fixture def tox_ini_conf(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ToxIniCreator: + """Return a factory for INI config files.""" + def func(conf: str, override: Sequence[Override] | None = None) -> Config: dest = tmp_path / "c" dest.mkdir() @@ -66,6 +68,32 @@ def func(conf: str, override: Sequence[Override] | None = None) -> Config: return func +class ToxTomlCreator(Protocol): + def __call__(self, conf: str, override: Sequence[Override] | None = None) -> Config: ... + + +@pytest.fixture +def tox_toml_conf(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ToxTomlCreator: + """Return a factory for TOML config files.""" + + def func(conf: str, override: Sequence[Override] | None = None) -> Config: + dest = tmp_path / "c" + dest.mkdir() + config_file = dest / "tox.toml" + config_file.write_bytes(conf.encode("utf-8")) + with monkeypatch.context() as context: + context.chdir(tmp_path) + source = discover_source(config_file, None) + + return Config.make( + Parsed(work_dir=dest, override=override or [], config_file=config_file, root_dir=None), + pos_args=[], + source=source, + ) + + return func + + @pytest.fixture(scope="session") def demo_pkg_setuptools() -> Path: return HERE / "demo_pkg_setuptools" diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py index 32a8e9fdc..a066c65ef 100644 --- a/tests/plugin/test_plugin.py +++ b/tests/plugin/test_plugin.py @@ -131,7 +131,7 @@ def test_plugin_can_set_core_conf( ) -> None: @impl def tox_add_core_config(core_conf: CoreConfigSet, state: State) -> None: # noqa: ARG001 - core_conf.loaders.insert(0, MemoryLoader(**{dir_name: tmp_path})) + core_conf.loaders.insert(0, MemoryLoader({dir_name: tmp_path})) register_inline_plugin(mocker, tox_add_core_config) @@ -186,7 +186,7 @@ def tox_add_core_config(core_conf: CoreConfigSet, state: State) -> None: # noqa def test_plugin_injects_invalid_python_run(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: @impl def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: # noqa: ARG001 - env_conf.loaders.insert(0, MemoryLoader(deps=[1])) + env_conf.loaders.insert(0, MemoryLoader({"deps": [1]})) with pytest.raises(TypeError, match="1"): assert env_conf["deps"] diff --git a/tests/session/cmd/test_legacy.py b/tests/session/cmd/test_legacy.py index 508e4ec9c..cb99989d7 100644 --- a/tests/session/cmd/test_legacy.py +++ b/tests/session/cmd/test_legacy.py @@ -65,7 +65,7 @@ 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 tox.toml or pyproject.toml or setup.cfg or pyproject.toml found, assuming empty tox.ini at {project.path}\n" assert not outcome.err assert outcome.out == out