Skip to content

Commit 81d8a02

Browse files
committed
🐛 fix(toml): split posargs string into separate args
A TOML command list entry holding only {posargs} was replaced with the shell quoted join of the positional arguments, so `tox -- tests src` handed the command a single `tests src` argument. Every INI user and most TOML users read that entry as a stand-in for the arguments themselves. The replacement already shell quotes what it produces, so splitting the result back apart round-trips arguments containing spaces without a second code path. The splitter that `to_command` used is now shared, so both loaders agree on quoting and Windows path handling.
1 parent 930190c commit 81d8a02

5 files changed

Lines changed: 117 additions & 50 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+
A TOML command entry that is nothing but ``{posargs}`` now expands to one entry per positional argument, instead of
2+
collapsing them into a single shell quoted argument - by :user:`gaborbernat`.

docs/reference/config.rst

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2424,15 +2424,18 @@ If the positional arguments are not set commands will become ``python a b``, oth
24242424
The ``extend`` option instructs tox to unroll the positional arguments within the host structure. Without it the result
24252425
would become ``["python", ["a", "b"]`` which would be invalid.
24262426

2427-
Note that:
2427+
The ``{posargs}`` string form gives the same result:
24282428

24292429
.. code-block:: toml
24302430
24312431
[env.A]
2432-
commands = [["python", "{posargs}" ]]
2432+
commands = [["python", "{posargs:a b}" ]]
24332433
2434-
Differs in sense that the positional arguments will be set as a single argument, while in the original example they are
2435-
passed through as separate.
2434+
.. versionchanged:: 4.61
2435+
2436+
A list entry that is nothing but ``{posargs}`` expands into one entry per positional argument. Earlier releases
2437+
passed them on as a single, shell quoted argument. Embed the reference in a larger string (``"--opt={posargs}"``) to
2438+
keep the old behavior.
24362439

24372440
Empty commands groups will be ignored:
24382441

src/tox/config/loader/str_convert.py

Lines changed: 54 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -53,56 +53,14 @@ def to_dict(value: str, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[
5353
msg = f"dictionary lines must be of form key=value, found {row!r}"
5454
raise TypeError(msg)
5555

56-
@staticmethod
57-
def _win32_process_path_backslash(value: str, escape: str, special_chars: str) -> str:
58-
"""Escape backslash in value that is not followed by a special character.
59-
60-
This allows windows paths to be written without double backslash, while retaining the POSIX backslash escape
61-
semantics for quotes and escapes.
62-
63-
"""
64-
result = []
65-
for ix, char in enumerate(value):
66-
result.append(char)
67-
if char == escape:
68-
last_char = value[ix - 1 : ix]
69-
if last_char == escape:
70-
continue
71-
next_char = value[ix + 1 : ix + 2]
72-
if next_char not in {escape, *special_chars}:
73-
result.append(escape) # escape escapes that are not themselves escaping a special character
74-
return "".join(result)
75-
7656
@staticmethod
7757
def to_command(value: str) -> Command:
7858
"""At this point, ``value`` has already been substituted out, and all punctuation / escapes are final.
7959
8060
Value will typically be stripped of whitespace when coming from an ini file.
8161
8262
"""
83-
value = value.replace(r"\#", "#")
84-
is_win = sys.platform == "win32"
85-
if is_win: # pragma: win32 cover
86-
s = shlex.shlex(posix=True)
87-
value = StrConvert._win32_process_path_backslash(
88-
value,
89-
escape=s.escape,
90-
special_chars=s.quotes,
91-
)
92-
splitter = shlex.shlex(value, posix=True)
93-
splitter.whitespace_split = True
94-
splitter.commenters = "" # comments handled earlier, and the shlex does not know escaped comment characters
95-
args: list[str] = []
96-
pos = 0
97-
try:
98-
for arg in splitter:
99-
if is_win and len(arg) > 1 and arg[0] == arg[-1] and arg.startswith(("'", '"')): # pragma: win32 cover
100-
# on Windows quoted arguments will remain quoted, strip it
101-
arg = arg[1:-1] # ruff:ignore[redefined-loop-name]
102-
args.append(arg)
103-
pos = cast("StringIO", splitter.instream).tell()
104-
except ValueError:
105-
args.append(value[pos:])
63+
args = split_args(value.replace(r"\#", "#"))
10664
if len(args) == 0:
10765
msg = f"attempting to parse {value!r} into a command failed"
10866
raise ValueError(msg)
@@ -134,4 +92,56 @@ def to_bool(value: str) -> bool:
13492
raise TypeError(msg)
13593

13694

137-
__all__ = ("StrConvert",)
95+
def split_args(value: str) -> list[str]:
96+
"""Split an already substituted shell-like string into its arguments.
97+
98+
:param value: the string to split, with all punctuation and escapes final
99+
100+
:returns: the arguments, with quoting resolved
101+
102+
"""
103+
is_win = sys.platform == "win32"
104+
if is_win: # pragma: win32 cover
105+
s = shlex.shlex(posix=True)
106+
value = _win32_process_path_backslash(value, escape=s.escape, special_chars=s.quotes)
107+
splitter = shlex.shlex(value, posix=True)
108+
splitter.whitespace_split = True
109+
splitter.commenters = "" # comments handled earlier, and the shlex does not know escaped comment characters
110+
args: list[str] = []
111+
pos = 0
112+
try:
113+
for arg in splitter:
114+
if is_win and len(arg) > 1 and arg[0] == arg[-1] and arg.startswith(("'", '"')): # pragma: win32 cover
115+
# on Windows quoted arguments will remain quoted, strip it
116+
arg = arg[1:-1] # ruff:ignore[redefined-loop-name]
117+
args.append(arg)
118+
pos = cast("StringIO", splitter.instream).tell()
119+
except ValueError:
120+
args.append(value[pos:])
121+
return args
122+
123+
124+
def _win32_process_path_backslash(value: str, escape: str, special_chars: str) -> str:
125+
"""Escape backslash in value that is not followed by a special character.
126+
127+
This allows windows paths to be written without double backslash, while retaining the POSIX backslash escape
128+
semantics for quotes and escapes.
129+
130+
"""
131+
result = []
132+
for ix, char in enumerate(value):
133+
result.append(char)
134+
if char == escape:
135+
last_char = value[ix - 1 : ix]
136+
if last_char == escape:
137+
continue
138+
next_char = value[ix + 1 : ix + 2]
139+
if next_char not in {escape, *special_chars}:
140+
result.append(escape) # escape escapes that are not themselves escaping a special character
141+
return "".join(result)
142+
143+
144+
__all__ = (
145+
"StrConvert",
146+
"split_args",
147+
)

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
from tox.config.loader.ini.factor import find_factor_groups
1717
from tox.config.loader.replacer import (
1818
MatchError,
19+
MatchExpression,
1920
MatchRecursionError,
2021
ReplaceReference,
22+
find_replace_expr,
2123
load_posargs,
2224
replace,
2325
replace_env,
2426
)
27+
from tox.config.loader.str_convert import split_args
2528
from tox.config.loader.stringify import stringify
2629
from tox.config.types import Command
2730

@@ -76,6 +79,11 @@ def __call__( # ruff:ignore[complex-structure, too-many-branches]
7679
# need to inspect every entry of the list to check for reference.
7780
res_list: list[TomlTypes] = []
7881
for val in value: # apply replacement for every entry
82+
if not skip_str and self.conf is not None and _is_posargs_expr(val):
83+
# a list entry that is nothing but a posargs reference stands for the arguments themselves, so
84+
# split the shell quoted replacement back apart instead of passing it on as one argument (#4047)
85+
res_list.extend(split_args(cast("str", self(val, depth))))
86+
continue
7987
got = self(val, depth, skip_str=skip_str)
8088
if isinstance(val, dict) and val.get("replace") and val.get("extend"):
8189
# ``extend`` spreads an iterable result (list, set of extras, ...) into the
@@ -147,6 +155,13 @@ def _replace_ref(self, value: dict[str, TomlTypes], depth: int, *, skip_str: boo
147155
return value
148156

149157

158+
def _is_posargs_expr(value: TomlTypes) -> bool:
159+
if not isinstance(value, str):
160+
return False
161+
parsed = find_replace_expr(value)
162+
return len(parsed) == 1 and isinstance(parsed[0], MatchExpression) and parsed[0].expr[0] == ["posargs"]
163+
164+
150165
def _replace_glob_toml(conf: Config | None, value: dict[str, Any]) -> list[TomlTypes] | str:
151166
pattern = validate(value.get("pattern"), str)
152167
if not pattern:

tests/config/source/test_toml_tox.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import textwrap
55
from typing import TYPE_CHECKING
66

7-
if TYPE_CHECKING:
8-
import pytest
7+
import pytest
8+
9+
from tox.execute.request import shell_cmd
910

11+
if TYPE_CHECKING:
1012
from tox.pytest import ToxProjectCreator
1113

1214

@@ -135,3 +137,38 @@ def test_config_in_toml_env_list_keyed_factor_description(tox_project: ToxProjec
135137
outcome = project.run("c", "-e", "sync-python-tt", "-k", "description")
136138
outcome.assert_success()
137139
outcome.assert_out_err("[testenv:sync-python-tt]\ndescription = Sync python to tt\n", "")
140+
141+
142+
@pytest.mark.parametrize(
143+
("posargs", "expected"),
144+
[
145+
pytest.param([], "python a b", id="default"),
146+
pytest.param(["--"], "python", id="cleared"),
147+
pytest.param(["--", "c", "d"], "python c d", id="set"),
148+
pytest.param(["--", "c d", "e"], f"python {shell_cmd(['c d'])} e", id="set-with-space"),
149+
],
150+
)
151+
def test_config_in_toml_posargs_string_is_split(
152+
tox_project: ToxProjectCreator, posargs: list[str], expected: str
153+
) -> None:
154+
project = tox_project({
155+
"tox.toml": """
156+
[env.A]
157+
commands = [["python", "{posargs:a b}"]]
158+
"""
159+
})
160+
outcome = project.run("c", "-e", "A", "-k", "commands", *posargs)
161+
outcome.assert_success()
162+
outcome.assert_out_err(f"[testenv:A]\ncommands = {expected}\n", "")
163+
164+
165+
def test_config_in_toml_posargs_string_within_argument_is_joined(tox_project: ToxProjectCreator) -> None:
166+
project = tox_project({
167+
"tox.toml": """
168+
[env.A]
169+
commands = [["python", "--opt={posargs}"]]
170+
"""
171+
})
172+
outcome = project.run("c", "-e", "A", "-k", "commands", "--", "c", "d")
173+
outcome.assert_success()
174+
outcome.assert_out_err(f"[testenv:A]\ncommands = python {shell_cmd(['--opt=c d'])}\n", "")

0 commit comments

Comments
 (0)