Skip to content

Commit ff8f606

Browse files
committed
Replacer: Allow LocalPath to pass through
Paths were already stored inside SectionReader._subs as path objects, however they are turned into strings when they go through Replacer, and various transforms occur on these paths, for example breaking paths containing '#' and ' '. This change defaults to path objects being retained through the Replacer, and path segments being joined together using path object joining semantics. This mode can be disabled for settings of type `path` by wrapping the value in quotes, and disabled for other values by disabling new global setting literal_paths. Fixes #763 and #924
1 parent 5cc7da8 commit ff8f606

File tree

3 files changed

+327
-19
lines changed

3 files changed

+327
-19
lines changed

docs/config.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ Global settings are defined under the ``tox`` section as:
143143
configure :conf:`basepython` in the global testenv without affecting environments
144144
that have implied base python versions.
145145

146+
.. conf:: literal_paths ^ true|false ^ true
147+
148+
.. versionadded:: 3.21.0
149+
150+
tox defaults to interpretting values commencing with a path as a literal path, with
151+
only segments inside ``{..}`` being substituted, without any need for quoting.
152+
153+
Disabling this setting to use shell-like syntax for all values, except settings of
154+
type ``path``.
155+
156+
For settings of type ``path``, shell-like syntax can be activate by commencing the
157+
value with a quotation mark.
158+
146159
.. conf:: isolated_build ^ true|false ^ false
147160

148161
.. versionadded:: 3.3.0

src/tox/config/__init__.py

Lines changed: 77 additions & 18 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
@@ -163,6 +164,8 @@ def postprocess(self, testenv_config, value):
163164
deps = []
164165
config = testenv_config.config
165166
for depline in value:
167+
if isinstance(depline, PathBase):
168+
depline = str(depline)
166169
m = re.match(r":(\w+):\s*(\S+)", depline)
167170
if m:
168171
iname, name = m.groups()
@@ -416,7 +419,7 @@ def export(self):
416419
# such as {} being escaped using \{\}, suitable for use with
417420
# os.environ .
418421
return {
419-
name: Replacer._unescape(value)
422+
name: str(value) if isinstance(value, PathBase) else Replacer._unescape(value)
420423
for name, value in self.items()
421424
if value is not self._DUMMY
422425
}
@@ -1151,6 +1154,8 @@ def line_of_default_to_zero(section, name=None):
11511154
hash_seed = config.option.hashseed
11521155
config.hashseed = hash_seed
11531156

1157+
config.literal_paths = reader.getbool("literal_paths", True)
1158+
11541159
reader.addsubstitutions(toxinidir=config.toxinidir, homedir=config.homedir)
11551160

11561161
if config.option.workdir is None:
@@ -1364,7 +1369,13 @@ def _list_section_factors(self, section):
13641369

13651370
def make_envconfig(self, name, section, subs, config, replace=True):
13661371
factors = set(name.split("-"))
1367-
reader = SectionReader(section, self._cfg, fallbacksections=["testenv"], factors=factors)
1372+
reader = SectionReader(
1373+
section,
1374+
self._cfg,
1375+
fallbacksections=["testenv"],
1376+
factors=factors,
1377+
literal_paths=config.literal_paths,
1378+
)
13681379
tc = TestenvConfig(name, config, factors, reader)
13691380
reader.addsubstitutions(
13701381
envname=name,
@@ -1578,6 +1589,7 @@ def __init__(
15781589
factors=(),
15791590
prefix=None,
15801591
posargs="",
1592+
literal_paths=True,
15811593
):
15821594
if prefix is None:
15831595
self.section_name = section_name
@@ -1590,6 +1602,7 @@ def __init__(
15901602
self._subststack = []
15911603
self._setenv = None
15921604
self.posargs = posargs
1605+
self.literal_paths = literal_paths
15931606

15941607
def get_environ_value(self, name):
15951608
if self._setenv is None:
@@ -1602,7 +1615,7 @@ def addsubstitutions(self, _posargs=None, **kw):
16021615
self.posargs = _posargs
16031616

16041617
def getpath(self, name, defaultpath, replace=True):
1605-
path = self.getstring(name, defaultpath, replace=replace)
1618+
path = self.getstring(name, defaultpath, replace=replace, is_path=True)
16061619
if path is not None:
16071620
toxinidir = self._subs["toxinidir"]
16081621
return toxinidir.join(path, abs=True)
@@ -1611,6 +1624,8 @@ def getlist(self, name, sep="\n"):
16111624
s = self.getstring(name, None)
16121625
if s is None:
16131626
return []
1627+
if isinstance(s, PathBase):
1628+
return [s]
16141629
return [x.strip() for x in s.split(sep) if x.strip()]
16151630

16161631
def getdict(self, name, default=None, sep="\n", replace=True):
@@ -1698,7 +1713,15 @@ def getargv_install_command(self, name, default="", replace=True):
16981713

16991714
return _ArgvlistReader.getargvlist(self, s, replace=replace)[0]
17001715

1701-
def getstring(self, name, default=None, replace=True, crossonly=False, no_fallback=False):
1716+
def getstring(
1717+
self,
1718+
name,
1719+
default=None,
1720+
replace=True,
1721+
crossonly=False,
1722+
no_fallback=False,
1723+
is_path=False,
1724+
):
17021725
x = None
17031726
sections = [self.section_name] + ([] if no_fallback else self.fallbacksections)
17041727
for s in sections:
@@ -1716,10 +1739,16 @@ def getstring(self, name, default=None, replace=True, crossonly=False, no_fallba
17161739
# process. Once they are unwrapped, we call apply factors
17171740
# again for those new dependencies.
17181741
x = self._apply_factors(x)
1719-
x = self._replace_if_needed(x, name, replace, crossonly)
1742+
x = self._replace_if_needed(x, name, replace, crossonly, is_path=is_path)
1743+
if isinstance(x, PathBase):
1744+
return x
17201745
x = self._apply_factors(x)
17211746

1722-
x = self._replace_if_needed(x, name, replace, crossonly)
1747+
if isinstance(x, PathBase):
1748+
raise RuntimeError(name)
1749+
return x
1750+
1751+
x = self._replace_if_needed(x, name, replace, crossonly, is_path=is_path)
17231752
return x
17241753

17251754
def getposargs(self, default=None):
@@ -1733,9 +1762,9 @@ def getposargs(self, default=None):
17331762
else:
17341763
return default or ""
17351764

1736-
def _replace_if_needed(self, x, name, replace, crossonly):
1765+
def _replace_if_needed(self, x, name, replace, crossonly, is_path=False):
17371766
if replace and x and hasattr(x, "replace"):
1738-
x = self._replace(x, name=name, crossonly=crossonly)
1767+
x = self._replace(x, name=name, crossonly=crossonly, is_path=is_path)
17391768
return x
17401769

17411770
def _apply_factors(self, s):
@@ -1754,14 +1783,16 @@ def factor_line(line):
17541783
lines = s.strip().splitlines()
17551784
return "\n".join(filter(None, map(factor_line, lines)))
17561785

1757-
def _replace(self, value, name=None, section_name=None, crossonly=False):
1786+
def _replace(self, value, name=None, section_name=None, crossonly=False, is_path=False):
17581787
if "{" not in value:
17591788
return value
17601789

17611790
section_name = section_name if section_name else self.section_name
17621791
self._subststack.append((section_name, name))
17631792
try:
1764-
replaced = Replacer(self, crossonly=crossonly).do_replace(value)
1793+
replaced = Replacer(self, crossonly=crossonly).do_replace(
1794+
value, is_path=is_path, literal_paths=self.literal_paths,
1795+
)
17651796
assert self._subststack.pop() == (section_name, name)
17661797
except tox.exception.MissingSubstitution:
17671798
if not section_name.startswith(testenvprefix):
@@ -1789,19 +1820,41 @@ def __init__(self, reader, crossonly=False):
17891820
self.reader = reader
17901821
self.crossonly = crossonly
17911822

1792-
def do_replace(self, value):
1823+
def do_replace(self, value, is_path=False, literal_paths=True):
17931824
"""
17941825
Recursively expand substitutions starting from the innermost expression
17951826
"""
17961827

1797-
def substitute_once(x):
1798-
return self.RE_ITEM_REF.sub(self._replace_match, x)
1799-
1800-
expanded = substitute_once(value)
1828+
def substitute_each(s):
1829+
parts = []
1830+
pos = 0
1831+
for match in self.RE_ITEM_REF.finditer(s):
1832+
start = match.start()
1833+
if start:
1834+
parts.append(s[pos:start])
1835+
parts.append(self._replace_match(match))
1836+
pos = match.end()
1837+
1838+
tail = s[pos:]
1839+
if tail:
1840+
parts.append(tail)
1841+
1842+
if not parts:
1843+
return ""
1844+
if (literal_paths or is_path) and isinstance(parts[0], PathBase):
1845+
if len(parts) == 1:
1846+
return parts[0]
1847+
return parts[0].join(*parts[1:])
1848+
1849+
return "".join(str(part) for part in parts)
1850+
1851+
expanded = substitute_each(value)
1852+
if isinstance(expanded, PathBase):
1853+
return expanded
18011854

18021855
while expanded != value: # substitution found
18031856
value = expanded
1804-
expanded = substitute_once(value)
1857+
expanded = substitute_each(value)
18051858

18061859
return expanded
18071860

@@ -1888,6 +1941,8 @@ def _replace_substitution(self, match):
18881941
val = self._substitute_from_other_section(sub_key)
18891942
if callable(val):
18901943
val = val()
1944+
if isinstance(val, PathBase):
1945+
return val
18911946
return str(val)
18921947

18931948

@@ -1949,8 +2004,12 @@ def processcommand(cls, reader, command, replace=True):
19492004

19502005
new_arg = ""
19512006
new_word = reader._replace(word)
1952-
new_word = reader._replace(new_word)
1953-
new_word = Replacer._unescape(new_word)
2007+
if not isinstance(new_word, PathBase):
2008+
new_word = reader._replace(new_word)
2009+
if not isinstance(new_word, PathBase):
2010+
new_word = Replacer._unescape(new_word)
2011+
if isinstance(new_word, PathBase):
2012+
new_word = str(new_word)
19542013
new_arg += new_word
19552014
newcommand += new_arg
19562015
else:

0 commit comments

Comments
 (0)