Skip to content
1 change: 1 addition & 0 deletions docs/changelog/3474.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TOML config did not accept set_env=file|<path> terminology, despite suggesting equal support as for INI.
22 changes: 20 additions & 2 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -551,8 +551,26 @@ 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


.. note::

Expand Down
23 changes: 21 additions & 2 deletions src/tox/config/loader/toml/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -55,15 +58,31 @@ def load_raw_from_root(self, path: str) -> TomlTypes:

def build( # noqa: PLR0913
self,
key: str, # noqa: ARG002
key: str,
of_type: type[_T],
factory: Factory[_T],
conf: Config | None,
raw: TomlTypes,
args: ConfigLoadArgs,
) -> _T:
delay_replace = inspect.isclass(of_type) and issubclass(of_type, SetEnv)

def replacer(raw_: str, args_: ConfigLoadArgs) -> str:
reference_replacer = Unroll(conf, self, args)
try:
replaced = str(reference_replacer(raw_)) # do replacements
except Exception as exception:
if isinstance(exception, HandledError):
raise
msg = f"replace failed in {args_.env_name}.{key} with {exception!r}"
raise HandledError(msg) from exception
return replaced

exploded = Unroll(conf=conf, loader=self, args=args)(raw)
return self.to(exploded, of_type, factory)
refactoried = self.to(exploded, of_type, factory)
if delay_replace:
refactoried.use_replacer(replacer, args=args) # type: ignore[attr-defined] # issubclass(to_type, SetEnv)
return refactoried

def found_keys(self) -> set[str]:
return set(self.content.keys()) - self._unused_exclude
Expand Down
12 changes: 10 additions & 2 deletions src/tox/config/set_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,13 +24,21 @@ def __init__( # noqa: C901
from .loader.replacer import MatchExpression, find_replace_expr # noqa: PLC0415

if isinstance(raw, dict):
self._raw = raw
# TOML 'file' attribute is to be handled separately later
self._raw = dict(raw)
if "file" in raw:
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():
# INI 'file|' attribute is to be handled separately later
if line.startswith("file|"):
self._env_files.append(line[len("file|") :])
else:
Expand Down
71 changes: 64 additions & 7 deletions tests/config/test_set_env.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,19 +51,31 @@ def test_set_env_bad_line() -> None:
SetEnv("A", "py", "py", Path())


_ConfType = Literal["ini", "toml"]


class EvalSetEnv(Protocol):
def __call__(
self,
tox_ini: str,
config: str,
conf_type: _ConfType = "ini",
extra_files: dict[str, Any] | None = ...,
from_cwd: Path | None = ...,
) -> SetEnv: ...


@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,
conf_type: _ConfType = "ini",
extra_files: dict[str, Any] | None = None,
from_cwd: Path | None = None,
) -> SetEnv:
if conf_type == "ini":
prj = tox_project({"tox.ini": config, **(extra_files or {})})
else:
prj = tox_project({"tox.toml": 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"]
Expand Down Expand Up @@ -149,7 +161,19 @@ 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(
("conf_type", "config"),
[
("ini", "[testenv]\npackage=skip\nset_env=file|A{/}a.txt\nchange_dir=C"),
("toml", '[env_run_base]\npackage="skip"\nset_env={file="A{/}a.txt"}\nchange_dir="C"'),
# Using monkeypatched env setting as a reference
("ini", "[testenv]\npackage=skip\nset_env=file|{env:myenvfile}\nchange_dir=C"),
("toml", '[env_run_base]\npackage="skip"\nset_env={file="{env:myenvfile}"}\nchange_dir="C"'),
],
)
def test_set_env_environment_file(
conf_type: _ConfType, config: str, eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch
) -> None:
env_file = """
A=1
B= 2
Expand All @@ -158,9 +182,11 @@ def test_set_env_environment_file(eval_set_env: EvalSetEnv) -> None:
E = "1"
F =
"""
# Monkeypatch only used for some of the parameters
monkeypatch.setenv("myenvfile", "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, conf_type=conf_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",
Expand All @@ -174,6 +200,37 @@ def test_set_env_environment_file(eval_set_env: EvalSetEnv) -> None:
}


@pytest.mark.parametrize(
("conf_type", "config"),
[
("ini", "[testenv]\npackage=skip\nset_env=file|A{/}a.txt\n X=y\nchange_dir=C"),
("toml", '[env_run_base]\npackage="skip"\nset_env={file="A{/}a.txt", X="y"}\nchange_dir="C"'),
# Using monkeypatched env setting as a reference
("ini", "[testenv]\npackage=skip\nset_env=file|{env:myenvfile}\n X=y\nchange_dir=C"),
("toml", '[env_run_base]\npackage="skip"\nset_env={file="{env:myenvfile}", X="y"}\nchange_dir="C"'),
],
)
def test_set_env_environment_file_combined_with_normal_setting(
conf_type: _ConfType, config: str, eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch
) -> None:
env_file = """
A=1
"""
# Monkeypatch only used for some of the parameters
monkeypatch.setenv("myenvfile", "A{/}a.txt")

extra = {"A": {"a.txt": env_file}, "B": None, "C": None}
set_env = eval_set_env(config, conf_type=conf_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")
Expand Down