Skip to content

Commit 0006a12

Browse files
authored
Fix regression with multiple env substitutions for the same key (#2873)
Fix #2869
1 parent 2d46a1e commit 0006a12

File tree

3 files changed

+50
-6
lines changed

3 files changed

+50
-6
lines changed

docs/changelog/2869.bugfix.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix regression introduced in 4.3.0 which occured when a substitution expression
2+
for an environment variable that had previously been substituted appears in the
3+
ini file after a substitution expression for a different environment variable.
4+
This situation erroneously resulted in an exception about "circular chain
5+
between set" of those variables - by :user:`masenf`.

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,29 +178,32 @@ def join(self, value: MatchArg) -> str:
178178
return "".join(self(value))
179179

180180
def _replace_match(self, value: MatchExpression) -> str:
181+
# use a copy of conf_args so any changes from this replacement do NOT
182+
# affect adjacent substitutions (#2869)
183+
conf_args = self.conf_args.copy()
181184
of_type, *args = flattened_args = [self.join(arg) for arg in value.expr]
182185
if of_type == "/":
183186
replace_value: str | None = os.sep
184187
elif of_type == "" and args == [""]:
185188
replace_value = os.pathsep
186189
elif of_type == "env":
187-
replace_value = replace_env(self.conf, args, self.conf_args)
190+
replace_value = replace_env(self.conf, args, conf_args)
188191
elif of_type == "tty":
189192
replace_value = replace_tty(args)
190193
elif of_type == "posargs":
191-
replace_value = replace_pos_args(self.conf, args, self.conf_args)
194+
replace_value = replace_pos_args(self.conf, args, conf_args)
192195
else:
193196
replace_value = replace_reference(
194197
self.conf,
195198
self.loader,
196199
ARG_DELIMITER.join(flattened_args),
197-
self.conf_args,
200+
conf_args,
198201
)
199202
if replace_value is not None:
200203
needs_expansion = any(isinstance(m, MatchExpression) for m in find_replace_expr(replace_value))
201204
if needs_expansion:
202205
try:
203-
return replace(self.conf, self.loader, replace_value, self.conf_args, self.depth + 1)
206+
return replace(self.conf, self.loader, replace_value, conf_args, self.depth + 1)
204207
except MatchRecursionError as err:
205208
LOGGER.warning(str(err))
206209
return replace_value

tests/config/loader/ini/replace/test_replace_env_var.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77

88
from tests.config.loader.ini.replace.conftest import ReplaceOne
9-
from tox.pytest import MonkeyPatch
9+
from tox.pytest import LogCaptureFixture, MonkeyPatch
1010

1111

1212
def test_replace_env_set(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None:
@@ -75,6 +75,20 @@ def test_replace_env_missing_default_from_env(replace_one: ReplaceOne, monkeypat
7575
assert result == "yes"
7676

7777

78+
def test_replace_env_var_multiple(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None:
79+
"""Multiple env substitutions on a single line."""
80+
monkeypatch.setenv("MAGIC", "MAGIC")
81+
monkeypatch.setenv("TRAGIC", "TRAGIC")
82+
result = replace_one("{env:MAGIC} {env:TRAGIC} {env:MAGIC}")
83+
assert result == "MAGIC TRAGIC MAGIC"
84+
85+
86+
def test_replace_env_var_multiple_default(replace_one: ReplaceOne) -> None:
87+
"""Multiple env substitutions on a single line with default values."""
88+
result = replace_one("{env:MAGIC:foo} {env:TRAGIC:bar} {env:MAGIC:baz}")
89+
assert result == "foo bar baz"
90+
91+
7892
def test_replace_env_var_circular(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None:
7993
"""Replacement values will not infinitely loop"""
8094
monkeypatch.setenv("MAGIC", "{env:MAGIC}")
@@ -97,12 +111,34 @@ def avoid_infinite_loop() -> None: # pragma: no cover
97111

98112

99113
@pytest.mark.usefixtures("reset_env_var_after_delay")
100-
def test_replace_env_var_circular_flip_flop(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None:
114+
def test_replace_env_var_circular_flip_flop(
115+
replace_one: ReplaceOne,
116+
monkeypatch: MonkeyPatch,
117+
caplog: LogCaptureFixture,
118+
) -> None:
101119
"""Replacement values will not infinitely loop back and forth"""
102120
monkeypatch.setenv("TRAGIC", "{env:MAGIC}")
103121
monkeypatch.setenv("MAGIC", "{env:TRAGIC}")
104122
result = replace_one("{env:MAGIC}")
105123
assert result == "{env:MAGIC}"
124+
assert "circular chain between set env MAGIC, TRAGIC" in caplog.messages
125+
126+
127+
@pytest.mark.usefixtures("reset_env_var_after_delay")
128+
def test_replace_env_var_circular_flip_flop_5(
129+
replace_one: ReplaceOne,
130+
monkeypatch: MonkeyPatch,
131+
caplog: LogCaptureFixture,
132+
) -> None:
133+
"""Replacement values will not infinitely loop back and forth (longer chain)"""
134+
monkeypatch.setenv("MAGIC", "{env:TRAGIC}")
135+
monkeypatch.setenv("TRAGIC", "{env:RABBIT}")
136+
monkeypatch.setenv("RABBIT", "{env:HAT}")
137+
monkeypatch.setenv("HAT", "{env:TRICK}")
138+
monkeypatch.setenv("TRICK", "{env:MAGIC}")
139+
result = replace_one("{env:MAGIC}")
140+
assert result == "{env:MAGIC}"
141+
assert "circular chain between set env MAGIC, TRAGIC, RABBIT, HAT, TRICK" in caplog.messages
106142

107143

108144
@pytest.mark.parametrize("fallback", [True, False])

0 commit comments

Comments
 (0)