Skip to content

Commit e489adb

Browse files
authored
🐛 fix(config): substitute inside override values (#4048)
1 parent c1af929 commit e489adb

9 files changed

Lines changed: 109 additions & 3 deletions

File tree

docs/changelog/4047.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Values passed via ``--override``/``-x`` or ``TOX_OVERRIDE`` now resolve substitutions such as ``{posargs}``,
2+
``{env:VAR}`` and ``{env_name}``, instead of reaching the environment as literal text - by :user:`gaborbernat`.

docs/explanation.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,18 @@ shape one more key.
347347

348348
For the configuration reference, see :ref:`env-base-templates`. For practical recipes, see :ref:`howto_env_base_matrix`.
349349

350+
Overrides and substitution
351+
==========================
352+
353+
An override arrives as one command line string, whatever the configuration file format, so tox converts it with the ini
354+
string rules rather than the TOML ones. That is why ``-x env_run_base.commands=pytest tests`` works in a TOML project
355+
even though the file itself spells commands as a list of lists.
356+
357+
The value still goes through the substitution pass belonging to the loader it overrides, which is what lets
358+
``{posargs}`` and ``{env:VAR}`` resolve inside it. Skipping that pass would make an override the one place in tox where
359+
a ``{...}`` reference means nothing, and a command overridden for a single run would stop forwarding the arguments given
360+
to it with no sign that it had.
361+
350362
.. _work-dir-placement:
351363

352364
Work directory placement

docs/how-to/usage.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,13 @@ location can be changed via the ``TOX_CONFIG_FILE`` environment variable.
191191
# Force editable install for a specific environment
192192
tox run -e 3.13 -x "testenv:3.13.package=editable"
193193
194+
An override value takes substitutions, so it can reach the same values the configuration file can:
195+
196+
.. code-block:: bash
197+
198+
# Swap the test command for one run, keeping the positional arguments working
199+
tox run -e 3.13 -x 'env_run_base.commands=pytest {posargs:tests}' -- -k slow
200+
194201
.. _howto_out_of_tree_envs:
195202

196203
********************************************

docs/reference/config.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2281,6 +2281,21 @@ Or reset override and append to that (note the first override is ``=`` and not `
22812281
22822282
tox -x testenv.deps=pytest-xdist -x testenv.deps+=pytest-cov
22832283
2284+
Substitutions inside an override
2285+
================================
2286+
2287+
.. versionadded:: 4.61
2288+
2289+
An override value goes through the same substitution pass as a value written in the configuration file, so
2290+
``{posargs}``, ``{env:VAR}``, ``{env_name}`` and the rest resolve there too:
2291+
2292+
.. code-block:: bash
2293+
2294+
$ tox -x env_run_base.commands='pytest {posargs:tests}' -e py -- -k slow
2295+
2296+
This runs ``pytest -k slow``. Overrides carry a command line string whatever the configuration file format, so the ini
2297+
spelling of a substitution applies in a TOML project as well.
2298+
22842299
Overrides propagate through references
22852300
======================================
22862301

docs/tutorial/getting-started.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,8 @@ Pass extra arguments to the underlying tool using ``--``:
257257
tox run -e lint -- src/mymodule.py
258258
259259
The ``{ replace = "posargs" }`` in TOML (or ``{posargs}`` in INI) is a placeholder that gets replaced by whatever you
260-
pass after ``--``.
260+
pass after ``--``. The same placeholder works in a ``-x`` override, so you can swap a command for one run without losing
261+
the arguments you pass it.
261262

262263
**************************
263264
Understanding the output

src/tox/config/loader/api.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import inspect
34
from abc import abstractmethod
45
from argparse import ArgumentTypeError
56
from collections.abc import Iterable, Mapping
@@ -179,8 +180,15 @@ def load( # ruff:ignore[too-many-arguments]
179180
if not overrides:
180181
raise KeyError(key)
181182

183+
delay_replace = inspect.isclass(of_type) and issubclass(of_type, SetEnv)
182184
for override in overrides:
183-
converted_override = _STR_CONVERT.to(override.value, of_type, factory)
185+
# an override arrives as a raw CLI string, so it has not been through the loader's substitution pass yet
186+
raw_override = (
187+
override.value
188+
if delay_replace or conf is None # set_env expands later, the CLI config file never does
189+
else self.substitute(override.value, conf, args)
190+
)
191+
converted_override = _STR_CONVERT.to(raw_override, of_type, factory)
184192
if override.append and converted is not None:
185193
if isinstance(converted, list) and isinstance(converted_override, list):
186194
converted += converted_override
@@ -219,6 +227,18 @@ def build( # ruff:ignore[too-many-arguments]
219227
"""
220228
return self.to(raw, of_type, factory)
221229

230+
def substitute(self, value: str, conf: Config, args: ConfigLoadArgs) -> str:
231+
"""Apply this loader's replacements to a raw string.
232+
233+
:param value: the raw string
234+
:param conf: the global config
235+
:param args: env args
236+
237+
:returns: the string with every substitution this loader understands resolved
238+
239+
"""
240+
raise NotImplementedError
241+
222242

223243
def apply_overrides_to_raw(overrides: Iterable[Override], key: str, value: T) -> T:
224244
"""Fold the overrides targeting ``key`` onto a raw (pre-conversion) value.

src/tox/config/loader/ini/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ def replacer(raw_: str, args_: ConfigLoadArgs) -> str:
112112
cast("SetEnv", converted).use_replacer(replacer, args) # delay_replace means of_type is SetEnv
113113
return converted
114114

115+
def substitute(self, value: str, conf: Config, args: ConfigLoadArgs) -> str:
116+
return replace(conf, ReplaceReferenceIni(conf, self), value, args)
117+
115118
def found_keys(self) -> set[str]:
116119
return set(self._section_proxy.keys())
117120

src/tox/config/loader/toml/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ def _toml_replacer(value: str, args_: ConfigLoadArgs) -> str:
9292
cast("SetEnv", result).use_replacer(_toml_replacer, args=args) # delay_replace means of_type is SetEnv
9393
return result
9494

95+
def substitute(self, value: str, conf: Config, args: ConfigLoadArgs) -> str:
96+
return replace(conf, TomlReplaceLoader(conf, self), value, args)
97+
9598
def found_keys(self) -> set[str]:
9699
return set(self.content.keys()) - self._unused_exclude
97100

tests/config/loader/test_loader.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from tox.config.loader.api import Override, apply_overrides_to_raw
99

1010
if TYPE_CHECKING:
11-
from tox.pytest import CaptureFixture
11+
from tox.pytest import CaptureFixture, ToxProjectCreator
1212

1313

1414
@pytest.mark.parametrize("flag", ["-x", "--override"])
@@ -109,3 +109,46 @@ def test_apply_overrides_to_raw_ignores_other_keys() -> None:
109109
def test_apply_overrides_to_raw_append_unsupported_type() -> None:
110110
with pytest.raises(ValueError, match="Only able to append to lists, dicts and strings"):
111111
apply_overrides_to_raw([Override("ns.k+=1")], "k", 0)
112+
113+
114+
@pytest.mark.parametrize(
115+
("filename", "content", "namespace"),
116+
[
117+
pytest.param(
118+
"tox.toml",
119+
'env_list = ["a"]\n[env_run_base]\nskip_install = true\ncommands = [["python"]]\n',
120+
"env_run_base",
121+
id="toml",
122+
),
123+
pytest.param(
124+
"tox.ini",
125+
"[tox]\nenv_list = a\n[testenv]\nskip_install = true\ncommands = python\n",
126+
"testenv",
127+
id="ini",
128+
),
129+
],
130+
)
131+
@pytest.mark.parametrize(
132+
("override", "posargs", "expected"),
133+
[
134+
pytest.param("python {posargs}", ["--", "tests", "src"], "python tests src", id="posargs"),
135+
pytest.param("python {posargs:tests}", [], "python tests", id="posargs-default"),
136+
pytest.param("python {env:MAGIC}", [], "python from-env", id="env"),
137+
pytest.param("python {env_name}", [], "python a", id="env-name"),
138+
],
139+
)
140+
def test_override_value_is_substituted(
141+
tox_project: ToxProjectCreator,
142+
monkeypatch: pytest.MonkeyPatch,
143+
filename: str,
144+
content: str,
145+
namespace: str,
146+
override: str,
147+
posargs: list[str],
148+
expected: str,
149+
) -> None:
150+
monkeypatch.setenv("MAGIC", "from-env")
151+
project = tox_project({filename: content})
152+
outcome = project.run("c", "-e", "a", "-k", "commands", "-x", f"{namespace}.commands={override}", *posargs)
153+
outcome.assert_success()
154+
outcome.assert_out_err(f"[testenv:a]\ncommands = {expected}\n", "")

0 commit comments

Comments
 (0)