Repository: tox-dev/tox

Issue:
Tox should be able to detect interactive tty and alter some options based on this
It will be extremely useful if tox would allow users to detect an interactive tty and alter some execution options (like variables).

Example use case: adding `--pdb` to py.test arguments on interactive tty.

Any CI execution would run without an interactive TTY as this would be present only on developer terminal, a case where we may want to use an interactive debugger.

Now this is achievable using bash with something like
```
if [[ -t 1 ]]; then
  tox
else
  tox -- --pdb
fi
```
As you can see this is not only inconvenient but also creates more problems when you have multiple environments.

Reference: https://stackoverflow.com/questions/911168/how-to-detect-if-my-shell-script-is-running-through-a-pipe

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/changelog/947.feature.rst b/changelog/947.feature.rst
new file mode 100644
index 00000000..7fff5e71
--- /dev/null
+++ b/changelog/947.feature.rst
@@ -0,0 +1,1 @@
+allow injecting config value inside the ini file dependent of the fact that we're connected to an interactive shell or not  - by :user:`gaborbernat`
diff --git a/doc/config.rst b/doc/config.rst
index 89ce7a3a..5f6ab273 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
@@ -557,6 +564,17 @@ the above example is roughly equivalent to
 .. _`command positional substitution`:
 .. _`positional substitution`:
 
+interactive shell substitution
+++++++++++++++++++++++++++++++
+
+It's possible to inject a config value only when tox is running in interactive shell (standard input):
+
+    {tty:ON_VALUE:OFF_VALUE}
+
+The first value is the value to inject when the interactive terminal is available,
+the second value is the value to use when it's not. The later on is optional. A good use case
+for this is e.g. passing in the ``--pdb`` flag for pytest.
+
 substitutions for positional arguments in commands
 ++++++++++++++++++++++++++++++++++++++++++++++++++
 
diff --git a/src/tox/config.py b/src/tox/config.py
index 99714e6d..16f2c606 100755
--- a/src/tox/config.py
+++ b/src/tox/config.py
@@ -1430,6 +1430,10 @@ class Replacer:
 
         if sub_type == "env":
             return self._replace_env(match)
+        if sub_type == "tty":
+            if is_interactive():
+                return match.group("substitution_value")
+            return match.group("default_value")
         if sub_type is not None:
             raise tox.exception.ConfigError(
                 "No support for the {} substitution type".format(sub_type)
@@ -1437,16 +1441,16 @@ class Replacer:
         return self._replace_substitution(match)
 
     def _replace_env(self, match):
-        envkey = match.group("substitution_value")
-        if not envkey:
+        key = match.group("substitution_value")
+        if not key:
             raise tox.exception.ConfigError("env: requires an environment variable name")
         default = match.group("default_value")
-        envvalue = self.reader.get_environ_value(envkey)
-        if envvalue is not None:
-            return envvalue
+        value = self.reader.get_environ_value(key)
+        if value is not None:
+            return value
         if default is not None:
             return default
-        raise tox.exception.MissingSubstitution(envkey)
+        raise tox.exception.MissingSubstitution(key)
 
     def _substitute_from_other_section(self, key):
         if key.startswith("[") and "]" in key:
@@ -1475,6 +1479,10 @@ class Replacer:
         return str(val)
 
 
+def is_interactive():
+    return sys.stdin.isatty()
+
+
 class _ArgvlistReader:
     @classmethod
     def getargvlist(cls, reader, value, replace=True):
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_config.py b/tests/unit/test_config.py
index 78afe57e..3943b032 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -2724,3 +2724,29 @@ def test_config_bad_config_type_specified(monkeypatch, tmpdir, capsys):
     msg = "\n".join(notes) + "\n"
     assert err == msg
     assert "ERROR:" not in out
+
+
+def test_interactive_na(newconfig, monkeypatch):
+    monkeypatch.setattr(tox.config, "is_interactive", lambda: False)
+    config = newconfig(
+        """
+        [testenv:py]
+        setenv = A = {tty:X:Y}
+        """
+    )
+    assert config.envconfigs["py"].setenv["A"] == "Y"
+
+
+def test_interactive_available(newconfig, monkeypatch):
+    monkeypatch.setattr(tox.config, "is_interactive", lambda: True)
+    config = newconfig(
+        """
+        [testenv:py]
+        setenv = A = {tty:X:Y}
+        """
+    )
+    assert config.envconfigs["py"].setenv["A"] == "X"
+
+
+def test_interactive():
+    tox.config.is_interactive()
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")