Skip to content

Commit ee2ebe0

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

File tree

3 files changed

+186
-13
lines changed

3 files changed

+186
-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: 153 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,56 @@ 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 == [
543+
[str(envconfig.envbindir.join("python")), str(config.toxworkdir.realpath())]
544+
]
545+
546+
def test_command_substitution_whitespace(self, tmpdir, newconfig):
547+
"""Ensure spaces in path is kept in commands."""
548+
config = newconfig(
549+
"""
550+
[tox]
551+
toxworkdir = {toxinidir}/.tox dir
552+
553+
[testenv:py27]
554+
commands = {envpython} {toxworkdir}
555+
""",
556+
)
557+
558+
assert config.toxworkdir.realpath() == tmpdir.join(".tox dir").realpath()
559+
560+
envconfig = config.envconfigs["py27"]
561+
562+
assert envconfig.envbindir.realpath() in [
563+
tmpdir.join(".tox dir/py27/bin").realpath(),
564+
tmpdir.join(".tox dir/py27/Scripts").realpath(),
565+
]
566+
567+
assert envconfig.commands == [
568+
[str(envconfig.envbindir.join("python")), str(config.toxworkdir.realpath())]
569+
]
570+
499571

500572
class TestIniParser:
501573
def test_getstring_single(self, newconfig):
@@ -1084,6 +1156,46 @@ def test_envbindir(self, newconfig):
10841156
envconfig = config.envconfigs["python"]
10851157
assert envconfig.envpython == envconfig.envbindir.join("python")
10861158

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

1514-
def test_rewrite_simple_posargs(self, tmpdir, newconfig):
1626+
def test_rewrite_posargs_simple(self, tmpdir, newconfig):
15151627
inisource = """
15161628
[testenv:py27]
15171629
args_are_paths = True
@@ -1531,6 +1643,46 @@ def test_rewrite_simple_posargs(self, tmpdir, newconfig):
15311643
argv = conf.commands
15321644
assert argv[0] == ["cmd1", "hello"]
15331645

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

0 commit comments

Comments
 (0)