Repository: tox-dev/tox

Issue:
tox fails when running in a path containing a hash
On Archlinux with 2.9.1, when trying to run it in a folder called `test#foo`, I get:

```
$ tox -e flake8
flake8 create: /home/florian/proj/qutebrowser/test#foo/.tox/flake8
flake8 installdeps: -r/home/florian/proj/qutebrowser/test#foo/requirements.txt, -r/home/florian/proj/qutebrowser/test#foo/misc/requirements/requirements-flake8.txt
flake8 installed: attrs==17.3.0,colorama==0.3.9,cssutils==1.0.2,flake8==3.5.0,flake8-bugbear==17.12.0,flake8-builtins==1.0.post0,flake8-comprehensions==1.4.1,flake8-copyright==0.2.0,flake8-debugger==3.0.0,flake8-deprecated==1.3,flake8-docstrings==1.1.0,flake8-future-import==0.4.3,flake8-mock==0.3,flake8-per-file-ignores==0.4,flake8-polyfill==1.0.1,flake8-string-format==0.2.3,flake8-tidy-imports==1.1.0,flake8-tuple==0.2.13,Jinja2==2.10,MarkupSafe==1.0,mccabe==0.6.1,pep8-naming==0.4.1,pycodestyle==2.3.1,pydocstyle==2.1.1,pyflakes==1.6.0,Pygments==2.2.0,pyPEG2==2.15.2,PyYAML==3.12,six==1.11.0,snowballstemmer==1.2.1
flake8 runtests: PYTHONHASHSEED='2112495524'
flake8 runtests: commands[0] | /home/florian/proj/qutebrowser/test
ERROR: invocation failed (errno 2), args: ['/home/florian/proj/qutebrowser/test'], cwd: /home/florian/proj/qutebrowser/test#foo
Traceback (most recent call last):
  File "/bin/tox", line 11, in <module>
    load_entry_point('tox==2.9.1', 'console_scripts', 'tox')()
  File "/usr/lib/python3.6/site-packages/tox/session.py", line 40, in main
    retcode = Session(config).runcommand()
  File "/usr/lib/python3.6/site-packages/tox/session.py", line 392, in runcommand
    return self.subcommand_test()
  File "/usr/lib/python3.6/site-packages/tox/session.py", line 583, in subcommand_test
    self.runtestenv(venv)
  File "/usr/lib/python3.6/site-packages/tox/session.py", line 592, in runtestenv
    self.hook.tox_runtest(venv=venv, redirect=redirect)
  File "/usr/lib/python3.6/site-packages/pluggy/__init__.py", line 617, in __call__
    return self._hookexec(self, self._nonwrappers + self._wrappers, kwargs)
  File "/usr/lib/python3.6/site-packages/pluggy/__init__.py", line 222, in _hookexec
    return self._inner_hookexec(hook, methods, kwargs)
  File "/usr/lib/python3.6/site-packages/pluggy/__init__.py", line 216, in <lambda>
    firstresult=hook.spec_opts.get('firstresult'),
  File "/usr/lib/python3.6/site-packages/pluggy/callers.py", line 201, in _multicall
    return outcome.get_result()
  File "/usr/lib/python3.6/site-packages/pluggy/callers.py", line 76, in get_result
    raise ex[1].with_traceback(ex[2])
  File "/usr/lib/python3.6/site-packages/pluggy/callers.py", line 180, in _multicall
    res = hook_impl.function(*args)
  File "/usr/lib/python3.6/site-packages/tox/venv.py", line 464, in tox_runtest
    venv.test(redirect=redirect)
  File "/usr/lib/python3.6/site-packages/tox/venv.py", line 384, in test
    ignore_ret=ignore_ret, testcommand=True)
  File "/usr/lib/python3.6/site-packages/tox/venv.py", line 414, in _pcall
    redirect=redirect, ignore_ret=ignore_ret)
  File "/usr/lib/python3.6/site-packages/tox/session.py", line 140, in popen
    stdout=stdout, stderr=subprocess.STDOUT)
  File "/usr/lib/python3.6/site-packages/tox/session.py", line 228, in _popen
    stdout=stdout, stderr=stderr, env=env)
  File "/usr/lib/python3.6/subprocess.py", line 709, in __init__
    restore_signals, start_new_session)
  File "/usr/lib/python3.6/subprocess.py", line 1344, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
FileNotFoundError: [Errno 2] No such file or directory: '/home/florian/proj/qutebrowser/test': '/home/florian/proj/qutebrowser/test'
```

Nothing too special in tox.ini:

```ini
[testenv:flake8]
basepython = {env:PYTHON:python3}
passenv =
deps =
    -r{toxinidir}/requirements.txt
    -r{toxinidir}/misc/requirements/requirements-flake8.txt
commands =
    {envpython} -m flake8 {posargs:qutebrowser tests scripts}
```

I'm guessing `{envpython}` gets replaced by the Python path (which contains a `#`) and only after that, comments are stripped out?

Comments:

Patch:
diff --git a/docs/changelog/763.bugfix.rst b/docs/changelog/763.bugfix.rst
new file mode 100644
index 00000000..88cc3e0b
--- /dev/null
+++ b/docs/changelog/763.bugfix.rst
@@ -0,0 +1,1 @@
+Support ``#`` character in path for the tox project - by :user:`gaborbernat`.
diff --git a/src/tox/config/loader/ini/__init__.py b/src/tox/config/loader/ini/__init__.py
index 5822555e..55d67be3 100644
--- a/src/tox/config/loader/ini/__init__.py
+++ b/src/tox/config/loader/ini/__init__.py
@@ -29,8 +29,10 @@ class IniLoader(StrConvert, Loader[str]):
         super().__init__(overrides)
 
     def load_raw(self, key: str, conf: Optional["Config"], env_name: Optional[str]) -> str:
-        value = self._section[key]
+        return self.process_raw(conf, env_name, self._section[key])
 
+    @staticmethod
+    def process_raw(conf: Optional["Config"], env_name: Optional[str], value: str) -> str:
         # strip comments
         elements: List[str] = []
         for line in value.split("\n"):
@@ -38,7 +40,6 @@ class IniLoader(StrConvert, Loader[str]):
                 part = _COMMENTS.sub("", line)
                 elements.append(part.replace("\\#", "#"))
         strip_comments = "\n".join(elements)
-
         if conf is None:  # conf is None when we're loading the global tox configuration file for the CLI
             factor_filtered = strip_comments  # we don't support factor and replace functionality there
         else:
diff --git a/src/tox/config/loader/ini/replace.py b/src/tox/config/loader/ini/replace.py
index 3e9d193f..06facf61 100644
--- a/src/tox/config/loader/ini/replace.py
+++ b/src/tox/config/loader/ini/replace.py
@@ -120,9 +120,10 @@ def replace_reference(
             for src in _config_value_sources(settings["env"], settings["section"], current_env, conf, loader):
                 try:
                     if isinstance(src, SectionProxy):
-                        return src[key]
+                        return loader.process_raw(conf, current_env, src[key])
                     value = src.load(key, chain)
                     as_str, _ = stringify(value)
+                    as_str = as_str.replace("#", r"\#")  # escape comment characters as these will be stripped
                     return as_str
                 except KeyError as exc:  # if fails, keep trying maybe another source can satisfy
                     exception = exc
diff --git a/src/tox/config/loader/str_convert.py b/src/tox/config/loader/str_convert.py
index 01a114e6..ba373af8 100644
--- a/src/tox/config/loader/str_convert.py
+++ b/src/tox/config/loader/str_convert.py
@@ -47,8 +47,10 @@ class StrConvert(Convert[str]):
     @staticmethod
     def to_command(value: str) -> Command:
         is_win = sys.platform == "win32"
+        value = value.replace(r"\#", "#")
         splitter = shlex.shlex(value, posix=not is_win)
         splitter.whitespace_split = True
+        splitter.commenters = ""  # comments handled earlier, and the shlex does not know escaped comment characters
         args: List[str] = []
         pos = 0
         try:
diff --git a/src/tox/config/set_env.py b/src/tox/config/set_env.py
index ce836516..acfd52fa 100644
--- a/src/tox/config/set_env.py
+++ b/src/tox/config/set_env.py
@@ -41,6 +41,7 @@ class SetEnv:
             return self._materialized[item]
         raw = self._raw[item]
         result = self.replacer(raw, chain)  # apply any replace options
+        result = result.replace(r"\#", "#")  # unroll escaped comment with replacement
         self._materialized[item] = result
         self._raw.pop(item, None)  # if the replace requires the env we may be called again, so allow pop to fail
         return result
diff --git a/whitelist.txt b/whitelist.txt
index c8163050..c5fd2f25 100644
--- a/whitelist.txt
+++ b/whitelist.txt
@@ -27,6 +27,7 @@ chdir
 cmd
 codec
 colorama
+commenters
 conf
 configs
 conftest


Test patch:
diff --git a/src/tox/pytest.py b/src/tox/pytest.py
index 73123355..1516297c 100644
--- a/src/tox/pytest.py
+++ b/src/tox/pytest.py
@@ -370,7 +370,9 @@ class ToxRunOutcome:
 
 
 class ToxProjectCreator(Protocol):
-    def __call__(self, files: Dict[str, Any], base: Optional[Path] = None) -> ToxProject:  # noqa: U100
+    def __call__(
+        self, files: Dict[str, Any], base: Optional[Path] = None, prj_path: Optional[Path] = None  # noqa: U100
+    ) -> ToxProject:
         ...
 
 
@@ -378,9 +380,9 @@ class ToxProjectCreator(Protocol):
 def init_fixture(
     tmp_path: Path, capfd: CaptureFixture, monkeypatch: MonkeyPatch, mocker: MockerFixture
 ) -> ToxProjectCreator:
-    def _init(files: Dict[str, Any], base: Optional[Path] = None) -> ToxProject:
+    def _init(files: Dict[str, Any], base: Optional[Path] = None, prj_path: Optional[Path] = None) -> ToxProject:
         """create tox  projects"""
-        return ToxProject(files, base, tmp_path / "p", capfd, monkeypatch, mocker)
+        return ToxProject(files, base, prj_path or tmp_path / "p", capfd, monkeypatch, mocker)
 
     return _init  # noqa
 
diff --git a/tests/session/cmd/test_show_config.py b/tests/session/cmd/test_show_config.py
index bfeec554..366766ff 100644
--- a/tests/session/cmd/test_show_config.py
+++ b/tests/session/cmd/test_show_config.py
@@ -1,6 +1,8 @@
 import platform
 import sys
 from configparser import ConfigParser
+from pathlib import Path
+from textwrap import dedent
 from typing import Callable, Tuple
 
 import pytest
@@ -155,3 +157,27 @@ def test_show_config_description_normalize(tox_project: ToxProjectCreator) -> No
     outcome = tox_project({"tox.ini": tox_ini}).run("c", "-e", "py", "-k", "description")
     outcome.assert_success()
     assert outcome.out == "[testenv:py]\ndescription = A magical pipe of this\n"
+
+
+def test_show_config_ini_comment_path(tox_project: ToxProjectCreator, tmp_path: Path) -> None:
+    prj_path = tmp_path / "#magic"
+    prj_path.mkdir()
+    ini = """
+    [testenv]
+    package = skip
+    set_env =
+        A=1 # comment
+        # more comment
+    commands = {envpython} -c 'import os; print(os.linesep.join(f"{k}={v}" for k, v in os.environ.items()))'
+    [testenv:py]
+    set_env =
+        {[testenv]set_env}
+        B = {tox_root} # just some comment
+    """
+    project = tox_project({"tox.ini": dedent(ini)}, prj_path=prj_path)
+    result = project.run("r", "-e", "py")
+    result.assert_success()
+    a_line = next(i for i in result.out.splitlines() if i.startswith("A="))  # pragma: no branch  # not found raises
+    assert a_line == "A=1"
+    b_line = next(i for i in result.out.splitlines() if i.startswith("B="))  # pragma: no branch  # not found raises
+    assert b_line == f"B={prj_path}"