Skip to content

Commit e19069f

Browse files
chuenchen309claudepre-commit-ci[bot]
authored
Do not let a lone quote in a set_env value swallow the marker (#3988)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a1f76cf commit e19069f

3 files changed

Lines changed: 35 additions & 14 deletions

File tree

docs/changelog/3988.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Stop a lone quote in a ``set_env`` value (e.g. an apostrophe in ``can't``) from swallowing the ``;`` platform marker,
2+
which silently set the variable unconditionally with the marker text left in the value - by :user:`chuenchen309`.

src/tox/config/set_env.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,20 +126,25 @@ def _extract_key_value_marker(line: str) -> tuple[str, str, str]:
126126
@staticmethod
127127
def _split_value_marker(value: str) -> tuple[str, str]:
128128
# Parse value; marker format (PEP-496 style)
129-
# Handle escaped semicolons (\;) and quoted strings
130-
in_quotes = False
131-
quote_char = ""
132-
i = 0
133-
while i < len(value):
134-
char = value[i]
135-
if char in {'"', "'"} and (i == 0 or value[i - 1] != "\\"):
136-
if not in_quotes:
137-
in_quotes, quote_char = True, char
138-
elif char == quote_char:
139-
in_quotes = False
140-
elif char == ";" and not in_quotes and (i == 0 or value[i - 1] != "\\"):
141-
return value[:i].strip().replace("\\;", ";"), value[i + 1 :].strip()
142-
i += 1
129+
# Handle escaped semicolons (\;) and quoted strings. Quotes keep a ";" inside a quoted value from being read
130+
# as the marker separator, but only when balanced -- a lone quote (e.g. an apostrophe in the value) must not
131+
# swallow the marker, so retry ignoring quotes if one is left open.
132+
for respect_quotes in (True, False):
133+
in_quotes = False
134+
quote_char = ""
135+
index = 0
136+
while index < len(value):
137+
char = value[index]
138+
if respect_quotes and char in {'"', "'"} and (index == 0 or value[index - 1] != "\\"):
139+
if not in_quotes:
140+
in_quotes, quote_char = True, char
141+
elif char == quote_char:
142+
in_quotes = False
143+
elif char == ";" and not in_quotes and (index == 0 or value[index - 1] != "\\"):
144+
return value[:index].strip().replace("\\;", ";"), value[index + 1 :].strip()
145+
index += 1
146+
if not in_quotes:
147+
break # quotes balanced or absent: the first pass is authoritative
143148
return value.replace("\\;", ";"), ""
144149

145150
def load(self, item: str, args: ConfigLoadArgs | None = None) -> str:

tests/config/test_set_env.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,20 @@ def test_set_env_environment_with_file_and_expanded_substitution(
326326
None,
327327
id="ini-marker-false",
328328
),
329+
pytest.param(
330+
"ini",
331+
f"[testenv]\npackage=skip\nset_env=CONDITIONAL=can't; sys_platform == '{sys.platform}'",
332+
True,
333+
"can't",
334+
id="ini-marker-true-apostrophe-value",
335+
),
336+
pytest.param(
337+
"ini",
338+
"[testenv]\npackage=skip\nset_env=CONDITIONAL=can't; sys_platform == 'nonexistent'",
339+
False,
340+
None,
341+
id="ini-marker-false-apostrophe-value",
342+
),
329343
pytest.param(
330344
"toml",
331345
f'[env_run_base]\npackage="skip"\nset_env.CONDITIONAL = {{ value = "value", '

0 commit comments

Comments
 (0)