Skip to content

Commit 7ce627e

Browse files
committed
Quote setting paths containing pound and space
1 parent e4d0d60 commit 7ce627e

File tree

3 files changed

+189
-13
lines changed

3 files changed

+189
-13
lines changed

docs/changelog/763.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Quote paths in settings containing ` ` or `#`. - by :user:`jayvdb`

src/tox/config/__init__.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import toml
2121
from packaging import requirements
2222
from packaging.utils import canonicalize_name
23+
from py._path.common import PathBase
2324

2425
import tox
2526
from tox.constants import INFO
@@ -390,7 +391,7 @@ def get(self, name, default=None):
390391
return os.environ.get(name, default)
391392
self._lookupstack.append(name)
392393
try:
393-
res = self.reader._replace(val)
394+
res = self.reader._replace(val, unquote_path=False)
394395
res = res.replace("\\{", "{").replace("\\}", "}")
395396
self.resolved[name] = res
396397
finally:
@@ -1591,7 +1592,7 @@ def addsubstitutions(self, _posargs=None, **kw):
15911592
self.posargs = _posargs
15921593

15931594
def getpath(self, name, defaultpath, replace=True):
1594-
path = self.getstring(name, defaultpath, replace=replace)
1595+
path = self.getstring(name, defaultpath, replace=replace, unquote_path=True)
15951596
if path is not None:
15961597
toxinidir = self._subs["toxinidir"]
15971598
return toxinidir.join(path, abs=True)
@@ -1680,7 +1681,15 @@ def getargv_install_command(self, name, default="", replace=True):
16801681

16811682
return _ArgvlistReader.getargvlist(self, s, replace=replace)[0]
16821683

1683-
def getstring(self, name, default=None, replace=True, crossonly=False, no_fallback=False):
1684+
def getstring(
1685+
self,
1686+
name,
1687+
default=None,
1688+
replace=True,
1689+
crossonly=False,
1690+
no_fallback=False,
1691+
unquote_path=False,
1692+
):
16841693
x = None
16851694
sections = [self.section_name] + ([] if no_fallback else self.fallbacksections)
16861695
for s in sections:
@@ -1698,10 +1707,10 @@ def getstring(self, name, default=None, replace=True, crossonly=False, no_fallba
16981707
# process. Once they are unwrapped, we call apply factors
16991708
# again for those new dependencies.
17001709
x = self._apply_factors(x)
1701-
x = self._replace_if_needed(x, name, replace, crossonly)
1710+
x = self._replace_if_needed(x, name, replace, crossonly, unquote_path=unquote_path)
17021711
x = self._apply_factors(x)
17031712

1704-
x = self._replace_if_needed(x, name, replace, crossonly)
1713+
x = self._replace_if_needed(x, name, replace, crossonly, unquote_path=unquote_path)
17051714
return x
17061715

17071716
def getposargs(self, default=None):
@@ -1715,9 +1724,9 @@ def getposargs(self, default=None):
17151724
else:
17161725
return default or ""
17171726

1718-
def _replace_if_needed(self, x, name, replace, crossonly):
1727+
def _replace_if_needed(self, x, name, replace, crossonly, unquote_path=False):
17191728
if replace and x and hasattr(x, "replace"):
1720-
x = self._replace(x, name=name, crossonly=crossonly)
1729+
x = self._replace(x, name=name, crossonly=crossonly, unquote_path=unquote_path)
17211730
return x
17221731

17231732
def _apply_factors(self, s):
@@ -1736,14 +1745,17 @@ def factor_line(line):
17361745
lines = s.strip().splitlines()
17371746
return "\n".join(filter(None, map(factor_line, lines)))
17381747

1739-
def _replace(self, value, name=None, section_name=None, crossonly=False):
1748+
def _replace(self, value, name=None, section_name=None, crossonly=False, unquote_path=False):
17401749
if "{" not in value:
17411750
return value
17421751

17431752
section_name = section_name if section_name else self.section_name
17441753
self._subststack.append((section_name, name))
17451754
try:
1746-
replaced = Replacer(self, crossonly=crossonly).do_replace(value)
1755+
replacer = Replacer(self, crossonly=crossonly)
1756+
replaced = replacer.do_replace(value)
1757+
if unquote_path and replacer._path_quoted:
1758+
replaced = replaced.replace("'", "")
17471759
assert self._subststack.pop() == (section_name, name)
17481760
except tox.exception.MissingSubstitution:
17491761
if not section_name.startswith(testenvprefix):
@@ -1770,6 +1782,7 @@ class Replacer:
17701782
def __init__(self, reader, crossonly=False):
17711783
self.reader = reader
17721784
self.crossonly = crossonly
1785+
self._path_quoted = False
17731786

17741787
def do_replace(self, value):
17751788
"""
@@ -1853,6 +1866,7 @@ def _substitute_from_other_section(self, key):
18531866
name=item,
18541867
section_name=section,
18551868
crossonly=self.crossonly,
1869+
unquote_path=False,
18561870
)
18571871

18581872
raise tox.exception.ConfigError("substitution key {!r} not found".format(key))
@@ -1864,6 +1878,12 @@ def _replace_substitution(self, match):
18641878
val = self._substitute_from_other_section(sub_key)
18651879
if callable(val):
18661880
val = val()
1881+
if isinstance(val, PathBase):
1882+
val = str(val)
1883+
# XXX handle ' and " in paths
1884+
if "'" not in val and ("#" in val or " " in val):
1885+
val = "'{}'".format(val)
1886+
self._path_quoted = True
18671887
return str(val)
18681888

18691889

@@ -1895,7 +1915,7 @@ def getargvlist(cls, reader, value, replace=True):
18951915
current_command += line
18961916

18971917
if is_section_substitution(current_command):
1898-
replaced = reader._replace(current_command, crossonly=True)
1918+
replaced = reader._replace(current_command, crossonly=True, unquote_path=False)
18991919
commands.extend(cls.getargvlist(reader, replaced))
19001920
else:
19011921
commands.append(cls.processcommand(reader, current_command, replace))
@@ -1924,8 +1944,8 @@ def processcommand(cls, reader, command, replace=True):
19241944
continue
19251945

19261946
new_arg = ""
1927-
new_word = reader._replace(word)
1928-
new_word = reader._replace(new_word)
1947+
new_word = reader._replace(word, unquote_path=False)
1948+
new_word = reader._replace(new_word, unquote_path=False)
19291949
new_word = new_word.replace("\\{", "{").replace("\\}", "}")
19301950
new_arg += new_word
19311951
newcommand += new_arg

tests/unit/config/test_config.py

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,28 @@ def test_envdir_set_manually_with_substitutions(self, newconfig):
9191
envconfig = config.envconfigs["dev"]
9292
assert envconfig.envdir == config.toxworkdir.join("foobar")
9393

94+
def test_envdir_set_manually_with_pound(self, newconfig):
95+
config = newconfig(
96+
[],
97+
"""
98+
[testenv:dev]
99+
envdir = {toxworkdir}/foo#bar
100+
""",
101+
)
102+
envconfig = config.envconfigs["dev"]
103+
assert envconfig.envdir == config.toxworkdir.join("foo#bar")
104+
105+
def test_envdir_set_manually_with_whitespace(self, newconfig):
106+
config = newconfig(
107+
[],
108+
"""
109+
[testenv:dev]
110+
envdir = {toxworkdir}/foo bar
111+
""",
112+
)
113+
envconfig = config.envconfigs["dev"]
114+
assert envconfig.envdir == config.toxworkdir.join("foo bar")
115+
94116
def test_force_dep_version(self, initproj):
95117
"""
96118
Make sure we can override dependencies configured in tox.ini when using the command line
@@ -496,6 +518,58 @@ def test_regression_issue595(self, newconfig):
496518
assert config.envconfigs["bar"].setenv["VAR"] == "x"
497519
assert "VAR" not in config.envconfigs["baz"].setenv
498520

521+
def test_command_substitution_pound(self, tmpdir, newconfig):
522+
"""Ensure pound in path is kept in commands."""
523+
config = newconfig(
524+
"""
525+
[tox]
526+
toxworkdir = {toxinidir}/.tox#dir
527+
528+
[testenv:py27]
529+
commands = {envpython} {toxworkdir}
530+
""",
531+
)
532+
533+
assert config.toxworkdir.realpath() == tmpdir.join(".tox#dir").realpath()
534+
535+
envconfig = config.envconfigs["py27"]
536+
537+
assert envconfig.envbindir.realpath() in [
538+
tmpdir.join(".tox#dir", "py27", "bin").realpath(),
539+
tmpdir.join(".tox#dir", "py27", "Scripts").realpath(),
540+
]
541+
542+
assert envconfig.commands[0] == [
543+
str(envconfig.envbindir.join("python")),
544+
str(config.toxworkdir.realpath()),
545+
]
546+
547+
def test_command_substitution_whitespace(self, tmpdir, newconfig):
548+
"""Ensure spaces in path is kept in commands."""
549+
config = newconfig(
550+
"""
551+
[tox]
552+
toxworkdir = {toxinidir}/.tox dir
553+
554+
[testenv:py27]
555+
commands = {envpython} {toxworkdir}
556+
""",
557+
)
558+
559+
assert config.toxworkdir.realpath() == tmpdir.join(".tox dir").realpath()
560+
561+
envconfig = config.envconfigs["py27"]
562+
563+
assert envconfig.envbindir.realpath() in [
564+
tmpdir.join(".tox dir", "py27", "bin").realpath(),
565+
tmpdir.join(".tox dir", "py27", "Scripts").realpath(),
566+
]
567+
568+
assert envconfig.commands[0] == [
569+
str(envconfig.envbindir.join("python")),
570+
str(config.toxworkdir.realpath()),
571+
]
572+
499573

500574
class TestIniParser:
501575
def test_getstring_single(self, newconfig):
@@ -1084,6 +1158,46 @@ def test_envbindir(self, newconfig):
10841158
envconfig = config.envconfigs["python"]
10851159
assert envconfig.envpython == envconfig.envbindir.join("python")
10861160

1161+
def test_envbindir_with_pound(self, newconfig):
1162+
config = newconfig(
1163+
"""
1164+
[tox]
1165+
toxworkdir = {toxinidir}/.tox#dir
1166+
[testenv]
1167+
basepython=python
1168+
""",
1169+
)
1170+
assert len(config.envconfigs) == 1
1171+
envconfig = config.envconfigs["python"]
1172+
1173+
assert ".tox#dir" in str(envconfig.envbindir)
1174+
assert ".tox#dir" in str(envconfig.envpython)
1175+
1176+
assert "'" not in str(envconfig.envbindir)
1177+
assert "'" not in str(envconfig.envpython)
1178+
1179+
assert envconfig.envpython == envconfig.envbindir.join("python")
1180+
1181+
def test_envbindir_with_whitespace(self, newconfig):
1182+
config = newconfig(
1183+
"""
1184+
[tox]
1185+
toxworkdir = {toxinidir}/.tox dir
1186+
[testenv]
1187+
basepython=python
1188+
""",
1189+
)
1190+
assert len(config.envconfigs) == 1
1191+
envconfig = config.envconfigs["python"]
1192+
1193+
assert ".tox dir" in str(envconfig.envbindir)
1194+
assert ".tox dir" in str(envconfig.envpython)
1195+
1196+
assert "'" not in str(envconfig.envbindir)
1197+
assert "'" not in str(envconfig.envpython)
1198+
1199+
assert envconfig.envpython == envconfig.envbindir.join("python")
1200+
10871201
@pytest.mark.parametrize("bp", ["jython", "pypy", "pypy3"])
10881202
def test_envbindir_jython(self, newconfig, bp):
10891203
config = newconfig(
@@ -1511,7 +1625,7 @@ def test_rewrite_posargs(self, tmpdir, newconfig):
15111625
argv = conf.commands
15121626
assert argv[0] == ["cmd1", "hello"]
15131627

1514-
def test_rewrite_simple_posargs(self, tmpdir, newconfig):
1628+
def test_rewrite_posargs_simple(self, tmpdir, newconfig):
15151629
inisource = """
15161630
[testenv:py27]
15171631
args_are_paths = True
@@ -1531,6 +1645,47 @@ def test_rewrite_simple_posargs(self, tmpdir, newconfig):
15311645
argv = conf.commands
15321646
assert argv[0] == ["cmd1", "hello"]
15331647

1648+
def test_rewrite_posargs_pound(self, tmpdir, newconfig):
1649+
inisource = """
1650+
[testenv:py27]
1651+
args_are_paths = True
1652+
changedir = test#dir
1653+
commands = cmd1 {posargs:hello}
1654+
"""
1655+
conf = newconfig([], inisource).envconfigs["py27"]
1656+
argv = conf.commands
1657+
assert argv[0] == ["cmd1", "hello"]
1658+
1659+
if sys.platform != "win32":
1660+
conf = newconfig(["test#dir/hello"], inisource).envconfigs["py27"]
1661+
argv = conf.commands
1662+
assert argv[0] == ["cmd1", "test#dir/hello"]
1663+
1664+
tmpdir.ensure("test#dir", "hello")
1665+
conf = newconfig(["test#dir/hello"], inisource).envconfigs["py27"]
1666+
argv = conf.commands
1667+
assert argv[0] == ["cmd1", "hello"]
1668+
1669+
def test_rewrite_posargs_whitespace(self, tmpdir, newconfig):
1670+
inisource = """
1671+
[testenv:py27]
1672+
args_are_paths = True
1673+
changedir = test dir
1674+
commands = cmd1 {posargs:hello}
1675+
"""
1676+
conf = newconfig([], inisource).envconfigs["py27"]
1677+
argv = conf.commands
1678+
assert argv[0] == ["cmd1", "hello"]
1679+
1680+
conf = newconfig(["test dir/hello"], inisource).envconfigs["py27"]
1681+
argv = conf.commands
1682+
assert argv[0] == ["cmd1", "test dir/hello"]
1683+
1684+
tmpdir.ensure("test dir", "hello")
1685+
conf = newconfig(["test dir/hello"], inisource).envconfigs["py27"]
1686+
argv = conf.commands
1687+
assert argv[0] == ["cmd1", "hello"]
1688+
15341689
@pytest.mark.parametrize(
15351690
"envlist, deps",
15361691
[

0 commit comments

Comments
 (0)