Repository: tox-dev/tox

Issue:
bug: tox should run pip with PIP_USER=0
- [X] Minimal reproduceable example or detailed description, assign "bug"
- [X] OS and `pip list` output

tox cannot use pip to install in a venv if the user has a config file that defaults to --user installs (e.g. because they (I) intend to use the distro package manager when touching distro packages) -- because pip crashes in such settings.  By setting the PIP_USER environment variable to 0, tox could override that setting to force the use of in-venv installs.

Arch Linux Py3.6 (from distro) in a clean venv.
pip list
```
Package    Version
---------- -------
pip        9.0.3  
pluggy     0.6.0  
py         1.5.3  
setuptools 39.0.1 
six        1.11.0 
tox        3.0.0  
virtualenv 16.0.0
```
foo/setup.py
```
from setuptools import setup; setup(name="foo")
```
foo/tox.ini
```
[tox]
envlist = py36
```
~/.config/pip/pip.conf
```
[install]
user = true
```
Running tox yields
```
GLOB sdist-make: /tmp/foo/setup.py
py36 inst-nodeps: /tmp/foo/.tox/dist/foo-0.0.0.zip
ERROR: invocation failed (exit code 1), logfile: /tmp/foo/.tox/py36/log/py36-3.log
ERROR: actionid: py36
msg: installpkg
cmdargs: ['/tmp/foo/.tox/py36/bin/pip', 'install', '-U', '--no-deps', '/tmp/foo/.tox/dist/foo-0.0.0.zip']

Can not perform a '--user' install. User site-packages are not visible in this virtualenv.
Exception information:
Traceback (most recent call last):
  File "/tmp/foo/.tox/py36/lib/python3.6/site-packages/pip/_internal/basecommand.py", line 228, in main
    status = self.run(options, args)
  File "/tmp/foo/.tox/py36/lib/python3.6/site-packages/pip/_internal/commands/install.py", line 218, in run
    "Can not perform a '--user' install. User site-packages "
pip._internal.exceptions.InstallationError: Can not perform a '--user' install. User site-packages are not visible in this virtualenv.

py36 installed: foo==0.0.0
_____________________________________________________________________________________________ summary ______________________________________________________________________________________________
ERROR:   py36: InvocationError for command /tmp/foo/.tox/py36/bin/pip install -U --no-deps /tmp/foo/.tox/dist/foo-0.0.0.zip (see /tmp/foo/.tox/py36/log/py36-3.log) (exited with code 1)
```

Discussion:

Patch:
diff --git a/changelog/824.feature.rst b/changelog/824.feature.rst
new file mode 100644
index 00000000..aae99136
--- /dev/null
+++ b/changelog/824.feature.rst
@@ -0,0 +1,1 @@
+use the os environment variable ``TOX_SKIP_ENV`` to filter out tox environment names from the run list (set by :confval:`envlist`)  - by :user:`gaborbernat`
diff --git a/changelog/838.feature.rst b/changelog/838.feature.rst
new file mode 100644
index 00000000..b90ff843
--- /dev/null
+++ b/changelog/838.feature.rst
@@ -0,0 +1,1 @@
+always set ``PIP_USER=0`` (do not install into the user site package, but inside the virtual environment created) and ``PIP_NO_DEPS=0`` (installing without dependencies can cause broken package installations) inside tox - by :user:`gaborbernat`
diff --git a/doc/config.rst b/doc/config.rst
index 89ce7a3a..7c33646a 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -76,6 +76,13 @@ and will first lookup global tox settings in this section:
     * environment variable ``TOXENV``
     * ``tox.ini`` file's ``envlist``
 
+    .. versionadded:: 3.4.0
+
+        What tox environments are ran during the tox invocation can be further filtered
+        via the operating system environment variable ``TOX_SKIP_ENV`` regular expression
+        (e.g. ``py27.*`` means **don't** evaluate environments that start with the key ``py27``).
+        Skipped environments will be logged at level two verbosity level.
+
 .. confval:: ignore_basepython_conflict=True|False(default)
 
     .. versionadded:: 3.1.0
diff --git a/src/tox/session.py b/src/tox/session.py
index 7da1efcd..dc25a277 100644
--- a/src/tox/session.py
+++ b/src/tox/session.py
@@ -381,7 +381,7 @@ class Session:
         self._spec2pkg = {}
         self._name2venv = {}
         try:
-            self.venvlist = [self.getvenv(x) for x in self.config.envlist]
+            self.venvlist = [self.getvenv(x) for x in self.evaluated_env_list()]
         except LookupError:
             raise SystemExit(1)
         except tox.exception.ConfigError as e:
@@ -389,6 +389,18 @@ class Session:
             raise SystemExit(1)
         self._actions = []
 
+    def evaluated_env_list(self):
+        tox_env_filter = os.environ.get("TOX_SKIP_ENV")
+        tox_env_filter_re = re.compile(tox_env_filter) if tox_env_filter is not None else None
+        for name in self.config.envlist:
+            if tox_env_filter_re is not None and tox_env_filter_re.match(name):
+                msg = "skip environment {}, matches filter {!r}".format(
+                    name, tox_env_filter_re.pattern
+                )
+                self.report.verbosity1(msg)
+                continue
+            yield name
+
     @property
     def hook(self):
         return self.config.pluginmanager.hook
diff --git a/src/tox/venv.py b/src/tox/venv.py
index a10149a9..0209f915 100755
--- a/src/tox/venv.py
+++ b/src/tox/venv.py
@@ -5,6 +5,7 @@ import pipes
 import re
 import sys
 import warnings
+from itertools import chain
 
 import py
 
@@ -231,7 +232,7 @@ class VirtualEnv(object):
         setup_py = setupdir.join("setup.py")
         setup_cfg = setupdir.join("setup.cfg")
         args = [self.envconfig.envpython, str(setup_py), "--name"]
-        env = self._getenv()
+        env = self._get_os_environ()
         output = action.popen(args, cwd=setupdir, redirect=False, returnout=True, env=env)
         name = output.strip()
         args = [self.envconfig.envpython, "-c", "import sys; print(sys.path)"]
@@ -297,30 +298,26 @@ class VirtualEnv(object):
         return options
 
     def run_install_command(self, packages, action, options=()):
-        argv = self.envconfig.install_command[:]
-        i = argv.index("{packages}")
-        argv[i : i + 1] = packages
-        if "{opts}" in argv:
-            i = argv.index("{opts}")
-            argv[i : i + 1] = list(options)
+        def expand(val):
+            # expand an install command
+            if val == "{packages}":
+                for package in packages:
+                    yield package
+            elif val == "{opts}":
+                for opt in options:
+                    yield opt
+            else:
+                yield val
 
-        for x in ("PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"):
-            os.environ.pop(x, None)
+        cmd = list(chain.from_iterable(expand(val) for val in self.envconfig.install_command))
 
-        if "PYTHONPATH" not in self.envconfig.passenv:
-            # If PYTHONPATH not explicitly asked for, remove it.
-            if "PYTHONPATH" in os.environ:
-                self.session.report.warning(
-                    "Discarding $PYTHONPATH from environment, to override "
-                    "specify PYTHONPATH in 'passenv' in your configuration."
-                )
-                os.environ.pop("PYTHONPATH")
+        self.ensure_pip_os_environ_ok()
 
         old_stdout = sys.stdout
         sys.stdout = codecs.getwriter("utf8")(sys.stdout)
         try:
             self._pcall(
-                argv,
+                cmd,
                 cwd=self.envconfig.config.toxinidir,
                 action=action,
                 redirect=self.session.report.verbosity < 2,
@@ -328,6 +325,24 @@ class VirtualEnv(object):
         finally:
             sys.stdout = old_stdout
 
+    def ensure_pip_os_environ_ok(self):
+        for key in ("PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"):
+            os.environ.pop(key, None)
+        if "PYTHONPATH" not in self.envconfig.passenv:
+            # If PYTHONPATH not explicitly asked for, remove it.
+            if "PYTHONPATH" in os.environ:
+                self.session.report.warning(
+                    "Discarding $PYTHONPATH from environment, to override "
+                    "specify PYTHONPATH in 'passenv' in your configuration."
+                )
+                os.environ.pop("PYTHONPATH")
+
+        # installing packages at user level may mean we're not installing inside the venv
+        os.environ["PIP_USER"] = "0"
+
+        # installing without dependencies may lead to broken packages
+        os.environ["PIP_NO_DEPS"] = "0"
+
     def _install(self, deps, extraopts=None, action=None):
         if not deps:
             return
@@ -353,13 +368,13 @@ class VirtualEnv(object):
                 options.extend(extraopts)
             self.run_install_command(packages=packages, options=options, action=action)
 
-    def _getenv(self, testcommand=False):
-        if testcommand:
+    def _get_os_environ(self, is_test_command=False):
+        if is_test_command:
             # for executing tests we construct a clean environment
             env = {}
-            for envname in self.envconfig.passenv:
-                if envname in os.environ:
-                    env[envname] = os.environ[envname]
+            for env_key in self.envconfig.passenv:
+                if env_key in os.environ:
+                    env[env_key] = os.environ[env_key]
         else:
             # for executing non-test commands we use the full
             # invocation environment
@@ -377,7 +392,7 @@ class VirtualEnv(object):
             self.session.make_emptydir(self.envconfig.envtmpdir)
             self.envconfig.envtmpdir.ensure(dir=1)
             cwd = self.envconfig.changedir
-            env = self._getenv(testcommand=True)
+            env = self._get_os_environ(is_test_command=True)
             # Display PYTHONHASHSEED to assist with reproducibility.
             action.setactivity("runtests", "PYTHONHASHSEED={!r}".format(env.get("PYTHONHASHSEED")))
             for i, argv in enumerate(self.envconfig.commands):
@@ -405,7 +420,7 @@ class VirtualEnv(object):
                         action=action,
                         redirect=redirect,
                         ignore_ret=ignore_ret,
-                        testcommand=True,
+                        is_test_command=True,
                     )
                 except tox.exception.InvocationError as err:
                     if self.envconfig.ignore_outcome:
@@ -424,18 +439,28 @@ class VirtualEnv(object):
                     raise
 
     def _pcall(
-        self, args, cwd, venv=True, testcommand=False, action=None, redirect=True, ignore_ret=False
+        self,
+        args,
+        cwd,
+        venv=True,
+        is_test_command=False,
+        action=None,
+        redirect=True,
+        ignore_ret=False,
     ):
+        # construct environment variables
         os.environ.pop("VIRTUALENV_PYTHON", None)
+        env = self._get_os_environ(is_test_command=is_test_command)
+        bin_dir = str(self.envconfig.envbindir)
+        env["PATH"] = os.pathsep.join([bin_dir, os.environ["PATH"]])
+        self.session.report.verbosity2("setting PATH={}".format(env["PATH"]))
 
-        cwd.ensure(dir=1)
+        # get command
         args[0] = self.getcommandpath(args[0], venv, cwd)
         if sys.platform != "win32" and "TOX_LIMITED_SHEBANG" in os.environ:
             args = prepend_shebang_interpreter(args)
-        env = self._getenv(testcommand=testcommand)
-        bindir = str(self.envconfig.envbindir)
-        env["PATH"] = p = os.pathsep.join([bindir, os.environ["PATH"]])
-        self.session.report.verbosity2("setting PATH={}".format(p))
+
+        cwd.ensure(dir=1)  # ensure the cwd exists
         return action.popen(args, cwd=cwd, env=env, redirect=redirect, ignore_ret=ignore_ret)
 
 


Test patch:
diff --git a/tests/unit/session/test_session.py b/tests/unit/session/test_session.py
index 90b1b992..0e93f588 100644
--- a/tests/unit/session/test_session.py
+++ b/tests/unit/session/test_session.py
@@ -140,3 +140,73 @@ def test_skip_install_skip_package(cmd, initproj, mock_venv):
     )
     result = cmd("--notest")
     assert result.ret == 0
+
+
+@pytest.fixture()
+def venv_filter_project(initproj, cmd):
+    def func(*args):
+        initproj(
+            "pkg123-0.7",
+            filedefs={
+                "tox.ini": """
+                    [tox]
+                    envlist = {py27,py36}-{nocov,cov,diffcov}{,-extra}
+                    skipsdist = true
+
+                    [testenv]
+                    skip_install = true
+                    commands = python -c 'print("{envname}")'
+                """
+            },
+        )
+        result = cmd(*args)
+        assert result.ret == 0
+        active = [i.name for i in result.session.venvlist]
+        return active, result
+
+    yield func
+
+
+def test_venv_filter_empty_all_active(venv_filter_project, monkeypatch):
+    monkeypatch.delenv("TOX_SKIP_ENV", raising=False)
+    active, result = venv_filter_project("-a")
+    assert result.outlines == [
+        "py27-nocov",
+        "py27-nocov-extra",
+        "py27-cov",
+        "py27-cov-extra",
+        "py27-diffcov",
+        "py27-diffcov-extra",
+        "py36-nocov",
+        "py36-nocov-extra",
+        "py36-cov",
+        "py36-cov-extra",
+        "py36-diffcov",
+        "py36-diffcov-extra",
+    ]
+    assert active == result.outlines
+
+
+def test_venv_filter_match_all_none_active(venv_filter_project, monkeypatch):
+    monkeypatch.setenv("TOX_SKIP_ENV", ".*")
+    active, result = venv_filter_project("-a")
+    assert not active
+    existing_envs = result.outlines
+
+    _, result = venv_filter_project("-avv")
+    for name in existing_envs:
+        msg = "skip environment {}, matches filter '.*'".format(name)
+        assert msg in result.outlines
+
+
+def test_venv_filter_match_some_some_active(venv_filter_project, monkeypatch):
+    monkeypatch.setenv("TOX_SKIP_ENV", "py27.*")
+    active, result = venv_filter_project("-avvv")
+    assert active == [
+        "py36-nocov",
+        "py36-nocov-extra",
+        "py36-cov",
+        "py36-cov-extra",
+        "py36-diffcov",
+        "py36-diffcov-extra",
+    ]
diff --git a/tests/unit/test_venv.py b/tests/unit/test_venv.py
index 54a52511..5b830956 100644
--- a/tests/unit/test_venv.py
+++ b/tests/unit/test_venv.py
@@ -625,6 +625,8 @@ class TestVenvTest:
         assert "PIP_RESPECT_VIRTUALENV" not in os.environ
         assert "PIP_REQUIRE_VIRTUALENV" not in os.environ
         assert "__PYVENV_LAUNCHER__" not in os.environ
+        assert os.environ["PIP_USER"] == "0"
+        assert os.environ["PIP_NO_DEPS"] == "0"
 
     def test_pythonpath_usage(self, newmocksession, monkeypatch):
         monkeypatch.setenv("PYTHONPATH", "/my/awesome/library")