diff --git a/docs/changelog/3474.bugfix.rst b/docs/changelog/3474.bugfix.rst new file mode 100644 index 000000000..5cf3c29e3 --- /dev/null +++ b/docs/changelog/3474.bugfix.rst @@ -0,0 +1 @@ +Support ``set_env = { file = "conf{/}local.env"}`` for TOML format - by :user:`juditnovak`. diff --git a/docs/config.rst b/docs/config.rst index db1c27d73..fb099c3d9 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -551,8 +551,43 @@ Base options .. conf:: :keys: set_env, setenv - A dictionary of environment variables to set when running commands in the tox environment. Lines starting with a - ``file|`` prefix define the location of environment file. + A dictionary of environment variables to set when running commands in the tox environment. + + In addition, there is an option to include an existing environment file. See the different syntax for TOML and INI below. + + .. tab:: TOML + + .. code-block:: toml + + [tool.tox.env_run_base] + set_env = { file = "conf{/}local.env", TEST_TIMEOUT = 30 } + + .. tab:: INI + + .. code-block:: ini + + [testenv] + set_env = file|conf{/}local.env + TEST_TIMEOUT = 30 + + + The env file path may include previously defined tox variables: + + + .. tab:: TOML + + .. code-block:: toml + + [tool.tox.env_run_base] + set_env = { file = "{env:variable}" } + + .. tab:: INI + + .. code-block:: ini + + [testenv] + set_env = file|{env:variable} + .. note:: diff --git a/src/tox/config/loader/toml/__init__.py b/src/tox/config/loader/toml/__init__.py index fb402bcfe..4c4c6cb03 100644 --- a/src/tox/config/loader/toml/__init__.py +++ b/src/tox/config/loader/toml/__init__.py @@ -1,11 +1,14 @@ from __future__ import annotations +import inspect import logging from pathlib import Path from typing import TYPE_CHECKING, Dict, Iterator, List, Mapping, TypeVar, cast from tox.config.loader.api import ConfigLoadArgs, Loader, Override +from tox.config.set_env import SetEnv from tox.config.types import Command, EnvList +from tox.report import HandledError from ._api import TomlTypes from ._replace import Unroll @@ -63,7 +66,10 @@ def build( # noqa: PLR0913 args: ConfigLoadArgs, ) -> _T: exploded = Unroll(conf=conf, loader=self, args=args)(raw) - return self.to(exploded, of_type, factory) + result = self.to(exploded, of_type, factory) + if inspect.isclass(of_type) and issubclass(of_type, SetEnv): + result.use_replacer(lambda c, s: c, args=args) # type: ignore[attr-defined] # noqa: ARG005 + return result def found_keys(self) -> set[str]: return set(self.content.keys()) - self._unused_exclude @@ -107,5 +113,6 @@ def to_env_list(value: TomlTypes) -> EnvList: __all__ = [ + "HandledError", "TomlLoader", ] diff --git a/src/tox/config/set_env.py b/src/tox/config/set_env.py index cf29cee0c..0686200c3 100644 --- a/src/tox/config/set_env.py +++ b/src/tox/config/set_env.py @@ -11,7 +11,7 @@ class SetEnv: - def __init__( # noqa: C901 + def __init__( # noqa: C901, PLR0912 self, raw: str | dict[str, str] | list[dict[str, str]], name: str, env_name: str | None, root: Path ) -> None: self.changed = False @@ -25,13 +25,16 @@ def __init__( # noqa: C901 if isinstance(raw, dict): self._raw = raw + if "file" in raw: # environment files to be handled later + self._env_files.append(raw["file"]) + self._raw.pop("file") return if isinstance(raw, list): self._raw = reduce(lambda a, b: {**a, **b}, raw) return for line in raw.splitlines(): # noqa: PLR1702 if line.strip(): - if line.startswith("file|"): + if line.startswith("file|"): # environment files to be handled later self._env_files.append(line[len("file|") :]) else: try: diff --git a/tests/config/test_set_env.py b/tests/config/test_set_env.py index d4440ab22..f72a23299 100644 --- a/tests/config/test_set_env.py +++ b/tests/config/test_set_env.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from unittest.mock import ANY import pytest @@ -51,10 +51,15 @@ def test_set_env_bad_line() -> None: SetEnv("A", "py", "py", Path()) +ConfigFileFormat = Literal["ini", "toml"] + + class EvalSetEnv(Protocol): def __call__( self, - tox_ini: str, + config: str, + *, + of_type: ConfigFileFormat = "ini", extra_files: dict[str, Any] | None = ..., from_cwd: Path | None = ..., ) -> SetEnv: ... @@ -62,8 +67,14 @@ def __call__( @pytest.fixture def eval_set_env(tox_project: ToxProjectCreator) -> EvalSetEnv: - def func(tox_ini: str, extra_files: dict[str, Any] | None = None, from_cwd: Path | None = None) -> SetEnv: - prj = tox_project({"tox.ini": tox_ini, **(extra_files or {})}) + def func( + config: str, + *, + of_type: ConfigFileFormat = "ini", + extra_files: dict[str, Any] | None = None, + from_cwd: Path | None = None, + ) -> SetEnv: + prj = tox_project({f"tox.{of_type}": config, **(extra_files or {})}) result = prj.run("c", "-k", "set_env", "-e", "py", from_cwd=None if from_cwd is None else prj.path / from_cwd) result.assert_success() set_env: SetEnv = result.env_conf("py")["set_env"] @@ -149,7 +160,20 @@ def test_set_env_honor_override(eval_set_env: EvalSetEnv) -> None: assert set_env.load("PIP_DISABLE_PIP_VERSION_CHECK") == "0" -def test_set_env_environment_file(eval_set_env: EvalSetEnv) -> None: +@pytest.mark.parametrize( + ("of_type", "config"), + [ + pytest.param("ini", "[testenv]\npackage=skip\nset_env=file|A{/}a.txt\nchange_dir=C", id="ini"), + pytest.param("toml", '[env_run_base]\npackage="skip"\nset_env={file="A{/}a.txt"}\nchange_dir="C"', id="toml"), + pytest.param("ini", "[testenv]\npackage=skip\nset_env=file|{env:env_file}\nchange_dir=C", id="ini-env"), + pytest.param( + "toml", '[env_run_base]\npackage="skip"\nset_env={file="{env:env_file}"}\nchange_dir="C"', id="toml-env" + ), + ], +) +def test_set_env_environment_file( + of_type: ConfigFileFormat, config: str, eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch +) -> None: env_file = """ A=1 B= 2 @@ -158,9 +182,10 @@ def test_set_env_environment_file(eval_set_env: EvalSetEnv) -> None: E = "1" F = """ + monkeypatch.setenv("env_file", "A{/}a.txt") + extra = {"A": {"a.txt": env_file}, "B": None, "C": None} - ini = "[testenv]\npackage=skip\nset_env=file|A{/}a.txt\nchange_dir=C" - set_env = eval_set_env(ini, extra_files=extra, from_cwd=Path("B")) + set_env = eval_set_env(config, of_type=of_type, extra_files=extra, from_cwd=Path("B")) content = {k: set_env.load(k) for k in set_env} assert content == { "PIP_DISABLE_PIP_VERSION_CHECK": "1", @@ -174,6 +199,42 @@ def test_set_env_environment_file(eval_set_env: EvalSetEnv) -> None: } +@pytest.mark.parametrize( + ("of_type", "config"), + [ + pytest.param("ini", "[testenv]\npackage=skip\nset_env=file|A{/}a.txt\n X=y\nchange_dir=C", id="ini"), + pytest.param( + "toml", '[env_run_base]\npackage="skip"\nset_env={file="A{/}a.txt", X="y"}\nchange_dir="C"', id="toml" + ), + pytest.param("ini", "[testenv]\npackage=skip\nset_env=file|{env:env_file}\n X=y\nchange_dir=C", id="ini-env"), + pytest.param( + "toml", + '[env_run_base]\npackage="skip"\nset_env={file="{env:env_file}", X="y"}\nchange_dir="C"', + id="toml-env", + ), + ], +) +def test_set_env_environment_file_combined_with_normal_setting( + of_type: ConfigFileFormat, config: str, eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch +) -> None: + env_file = """ + A=1 + """ + # Monkeypatch only used for some of the parameters + monkeypatch.setenv("env_file", "A{/}a.txt") + + extra = {"A": {"a.txt": env_file}, "B": None, "C": None} + set_env = eval_set_env(config, of_type=of_type, extra_files=extra, from_cwd=Path("B")) + content = {k: set_env.load(k) for k in set_env} + assert content == { + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "PYTHONHASHSEED": ANY, + "A": "1", + "X": "y", + "PYTHONIOENCODING": "utf-8", + } + + def test_set_env_environment_file_missing(tox_project: ToxProjectCreator) -> None: project = tox_project({"tox.ini": "[testenv]\npackage=skip\nset_env=file|magic.txt"}) result = project.run("r")