Skip to content

TOML set_env file support #3478

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 6, 2025
1 change: 1 addition & 0 deletions docs/changelog/3474.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support ``set_env = { file = "conf{/}local.env"}`` for TOML format - by :user:`juditnovak`.
39 changes: 37 additions & 2 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down
9 changes: 8 additions & 1 deletion 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 @@ -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
Expand Down Expand Up @@ -107,5 +113,6 @@ def to_env_list(value: TomlTypes) -> EnvList:


__all__ = [
"HandledError",
"TomlLoader",
]
7 changes: 5 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 @@ -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:
Expand Down
75 changes: 68 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,30 @@ 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: ...


@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"]
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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")
Expand Down