Skip to content

Commit 99b849b

Browse files
authored
Recursive replace (#2864)
* test_replace_tox_env: add missing chain cases When a replacement references a replacement in a non-testenv section it should also be expanded * Recursive ini-value substitution Expand substitution expressions that result from a previous subsitution expression replacement value (up to 100 times). Fix #2863 * cr: changelog: fix trailing period * test_replace_tox_env: tests for MAX_REPLACE_DEPTH Create a long chain of substitution values and assert that they stop being processed after some time.
1 parent 6fe280a commit 99b849b

File tree

5 files changed

+78
-10
lines changed

5 files changed

+78
-10
lines changed

docs/changelog/2863.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 by expanding substitution expressions
2+
(``{...}``) that result from a previous subsitution's replacement value (up to
3+
100 times). Note that recursive expansion is strictly depth-first; no
4+
replacement value will ever affect adjacent characters nor will expansion ever
5+
occur over the result of more than one replacement - by :user:`masenf`.

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44
from __future__ import annotations
55

6+
import logging
67
import os
78
import re
89
import sys
@@ -21,28 +22,39 @@
2122
from tox.config.loader.ini import IniLoader
2223
from tox.config.main import Config
2324

25+
26+
LOGGER = logging.getLogger(__name__)
27+
28+
2429
# split alongside :, unless it's preceded by a single capital letter (Windows drive letter in paths)
2530
ARG_DELIMITER = ":"
2631
REPLACE_START = "{"
2732
REPLACE_END = "}"
2833
BACKSLASH_ESCAPE_CHARS = ["\\", ARG_DELIMITER, REPLACE_START, REPLACE_END, "[", "]"]
34+
MAX_REPLACE_DEPTH = 100
2935

3036

3137
MatchArg = Sequence[Union[str, "MatchExpression"]]
3238

3339

40+
class MatchRecursionError(ValueError):
41+
"""Could not stabalize on replacement value."""
42+
43+
44+
class MatchError(Exception):
45+
"""Could not find end terminator in MatchExpression."""
46+
47+
3448
def find_replace_expr(value: str) -> MatchArg:
3549
"""Find all replaceable tokens within value."""
3650
return MatchExpression.parse_and_split_to_terminator(value)[0][0]
3751

3852

39-
def replace(conf: Config, loader: IniLoader, value: str, args: ConfigLoadArgs) -> str:
53+
def replace(conf: Config, loader: IniLoader, value: str, args: ConfigLoadArgs, depth: int = 0) -> str:
4054
"""Replace all active tokens within value according to the config."""
41-
return Replacer(conf, loader, conf_args=args).join(find_replace_expr(value))
42-
43-
44-
class MatchError(Exception):
45-
"""Could not find end terminator in MatchExpression."""
55+
if depth > MAX_REPLACE_DEPTH:
56+
raise MatchRecursionError(f"Could not expand {value} after recursing {depth} frames")
57+
return Replacer(conf, loader, conf_args=args, depth=depth).join(find_replace_expr(value))
4658

4759

4860
class MatchExpression:
@@ -153,10 +165,11 @@ def _flatten_string_fragments(seq_of_str_or_other: Sequence[str | Any]) -> Seque
153165
class Replacer:
154166
"""Recursively expand MatchExpression against the config and loader."""
155167

156-
def __init__(self, conf: Config, loader: IniLoader, conf_args: ConfigLoadArgs):
168+
def __init__(self, conf: Config, loader: IniLoader, conf_args: ConfigLoadArgs, depth: int = 0):
157169
self.conf = conf
158170
self.loader = loader
159171
self.conf_args = conf_args
172+
self.depth = depth
160173

161174
def __call__(self, value: MatchArg) -> Sequence[str]:
162175
return [self._replace_match(me) if isinstance(me, MatchExpression) else str(me) for me in value]
@@ -184,6 +197,13 @@ def _replace_match(self, value: MatchExpression) -> str:
184197
self.conf_args,
185198
)
186199
if replace_value is not None:
200+
needs_expansion = any(isinstance(m, MatchExpression) for m in find_replace_expr(replace_value))
201+
if needs_expansion:
202+
try:
203+
return replace(self.conf, self.loader, replace_value, self.conf_args, self.depth + 1)
204+
except MatchRecursionError as err:
205+
LOGGER.warning(str(err))
206+
return replace_value
187207
return replace_value
188208
# else: fall through -- when replacement is not possible, treat `{` as if escaped.
189209
# If we cannot replace, keep what was there, and continue looking for additional replaces
@@ -302,7 +322,7 @@ def replace_env(conf: Config, args: list[str], conf_args: ConfigLoadArgs) -> str
302322
return set_env.load(key, conf_args)
303323
elif conf_args.chain[-1] != new_key: # if there's a chain but only self-refers than use os.environ
304324
circular = ", ".join(i[4:] for i in conf_args.chain[conf_args.chain.index(new_key) :])
305-
raise ValueError(f"circular chain between set env {circular}")
325+
raise MatchRecursionError(f"circular chain between set env {circular}")
306326

307327
if key in os.environ:
308328
return os.environ[key]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def test_replace_env_var_circular_flip_flop(replace_one: ReplaceOne, monkeypatch
102102
monkeypatch.setenv("TRAGIC", "{env:MAGIC}")
103103
monkeypatch.setenv("MAGIC", "{env:TRAGIC}")
104104
result = replace_one("{env:MAGIC}")
105-
assert result == "{env:TRAGIC}"
105+
assert result == "{env:MAGIC}"
106106

107107

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

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
from tests.config.loader.ini.replace.conftest import ReplaceOne
99
from tests.conftest import ToxIniCreator
10+
from tox.config.loader.ini.replace import MAX_REPLACE_DEPTH
1011
from tox.config.sets import ConfigSet
12+
from tox.pytest import LogCaptureFixture
1113
from tox.report import HandledError
1214

1315
EnvConfigCreator = Callable[[str], ConfigSet]
@@ -31,6 +33,47 @@ def test_replace_within_tox_env(example: EnvConfigCreator) -> None:
3133
assert result == "1"
3234

3335

36+
def test_replace_within_tox_env_chain(example: EnvConfigCreator) -> None:
37+
env_config = example("r = 1\no = {r}/2\np = {r} {o}")
38+
env_config.add_config(keys="r", of_type=str, default="r", desc="r")
39+
env_config.add_config(keys="o", of_type=str, default="o", desc="o")
40+
env_config.add_config(keys="p", of_type=str, default="p", desc="p")
41+
result = env_config["p"]
42+
assert result == "1 1/2"
43+
44+
45+
def test_replace_within_section_chain(tox_ini_conf: ToxIniCreator) -> None:
46+
config = tox_ini_conf("[vars]\na = 1\nb = {[vars]a}/2\nc = {[vars]a}/3\n[testenv:a]\nd = {[vars]b} {[vars]c}")
47+
env_config = config.get_env("a")
48+
env_config.add_config(keys="d", of_type=str, default="d", desc="d")
49+
result = env_config["d"]
50+
assert result == "1/2 1/3"
51+
52+
53+
@pytest.mark.parametrize("depth", [5, 99, 100, 101, 150, 256])
54+
def test_replace_within_section_chain_deep(caplog: LogCaptureFixture, tox_ini_conf: ToxIniCreator, depth: int) -> None:
55+
config = tox_ini_conf(
56+
"\n".join(
57+
[
58+
"[vars]",
59+
"a0 = 1",
60+
*(f"a{ix} = {{[vars]a{ix - 1}}}" for ix in range(1, depth + 1)),
61+
"[testenv:a]",
62+
"b = {[vars]a%s}" % depth,
63+
],
64+
),
65+
)
66+
env_config = config.get_env("a")
67+
env_config.add_config(keys="b", of_type=str, default="b", desc="b")
68+
result = env_config["b"]
69+
if depth > MAX_REPLACE_DEPTH:
70+
exp_stopped_at = "{[vars]a%s}" % (depth - MAX_REPLACE_DEPTH - 1)
71+
assert result == exp_stopped_at
72+
assert f"Could not expand {exp_stopped_at} after recursing {MAX_REPLACE_DEPTH + 1} frames" in caplog.messages
73+
else:
74+
assert result == "1"
75+
76+
3477
def test_replace_within_tox_env_missing_raises(example: EnvConfigCreator) -> None:
3578
env_config = example("o = {p}")
3679
env_config.add_config(keys="o", of_type=str, default="o", desc="o")

tests/config/test_set_env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def test_set_env_circular_use_os_environ(tox_project: ToxProjectCreator) -> None
108108
prj = tox_project({"tox.ini": "[testenv]\npackage=skip\nset_env=a={env:b}\n b={env:a}"})
109109
result = prj.run("c", "-e", "py")
110110
result.assert_success()
111-
assert "replace failed in py.set_env with ValueError" in result.out, result.out
111+
assert "replace failed in py.set_env with MatchRecursionError" in result.out, result.out
112112
assert "circular chain between set env a, b" in result.out, result.out
113113

114114

0 commit comments

Comments
 (0)