Repository: tox-dev/tox (python)

Issue:
Set TOX_ENVDIR environment variable
I think it would be nice if tox would set some environment variable(s), which would help to detect being run in tox.

I suggest setting `TOX_ENVDIR` to what the `{toxenvdir}` substitution provides, since this would allow tools like pytest-testmon to place their data files there.

Ref: https://github.com/tarpas/pytest-testmon/pull/100

Pull request description:
tox injects env vars to indicate running within tox
Resolves #847.

Patch:
diff --git a/changelog/847.feature.rst b/changelog/847.feature.rst
new file mode 100644
index 00000000..c658e196
--- /dev/null
+++ b/changelog/847.feature.rst
@@ -0,0 +1,2 @@
+tox will inject some environment variables that to indicate a command is running within tox: ``TOX_WORK_DIR`` env var is set to the tox work directory,
+``TOX_ENV_NAME`` is set to the current running tox environment name, ``TOX_ENV_DIR`` is set to the current tox environments working dir - by :user:`gaborbernat`
diff --git a/doc/config.rst b/doc/config.rst
index 5f6ab273..ebacd5c3 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -805,7 +805,6 @@ the following:
       ``mysql-py36``,
     - but not ``py2``, ``py36-sql`` or ``py36-mysql-dev``.
 
-
 Advanced settings
 -----------------
 
@@ -829,6 +828,19 @@ user can bypass the system's interpreter parser by defining the
 When the workaround is enabled, all tox-invoked text file executables will have
 their interpreter directive parsed by and explicitly executed by ``tox``.
 
+Injected environment variables
+==============================
+tox will inject the following environment variables that you can use to test that your command is running within tox:
+
+.. versionadded:: 3.4
+
+- ``TOX_WORK_DIR`` env var is set to the tox work directory
+- ``TOX_ENV_NAME`` is set to the current running tox environment name
+- ``TOX_ENV_DIR`` is set to the current tox environments working dir.
+
+:note: this applies for all tox envs (isolated packaging too) and all external
+ commands called (e.g. install command - pip).
+
 Other Rules and notes
 =====================
 
diff --git a/src/tox/_quickstart.py b/src/tox/_quickstart.py
index e43ca345..a4d641b0 100644
--- a/src/tox/_quickstart.py
+++ b/src/tox/_quickstart.py
@@ -271,7 +271,9 @@ def parse_args():
         default=".",
         help="Custom root directory to write config to. Defaults to current directory.",
     )
-    parser.add_argument("--version", action="version", version="%(prog)s {}".format(tox.__version__))
+    parser.add_argument(
+        "--version", action="version", version="%(prog)s {}".format(tox.__version__)
+    )
     return parser.parse_args()
 
 
diff --git a/src/tox/config.py b/src/tox/config.py
index fb0f1c58..62cb3768 100755
--- a/src/tox/config.py
+++ b/src/tox/config.py
@@ -524,6 +524,9 @@ def tox_addoption(parser):
         config = testenv_config.config
         if "PYTHONHASHSEED" not in setenv and config.hashseed is not None:
             setenv["PYTHONHASHSEED"] = config.hashseed
+
+        setenv["TOX_ENV_NAME"] = str(testenv_config.envname)
+        setenv["TOX_ENV_DIR"] = str(testenv_config.envdir)
         return setenv
 
     parser.add_testenv_attribute(
@@ -647,7 +650,7 @@ def tox_addoption(parser):
         # Flatten the list to deal with space-separated values.
         value = list(itertools.chain.from_iterable([x.split(" ") for x in value]))
 
-        passenv = {"PATH", "PIP_INDEX_URL", "LANG", "LANGUAGE", "LD_LIBRARY_PATH"}
+        passenv = {"PATH", "PIP_INDEX_URL", "LANG", "LANGUAGE", "LD_LIBRARY_PATH", "TOX_WORK_DIR"}
 
         # read in global passenv settings
         p = os.environ.get("TOX_TESTENV_PASSENV", None)
diff --git a/src/tox/package.py b/src/tox/package.py
index fdaacb0c..2573a5ac 100644
--- a/src/tox/package.py
+++ b/src/tox/package.py
@@ -172,7 +172,7 @@ def get_build_info(folder, report):
 
 def perform_isolated_build(build_info, package_venv, session, config):
     with session.newaction(
-        package_venv, "perform isolated build", package_venv.envconfig.envdir
+        package_venv, "perform-isolated-build", package_venv.envconfig.envdir
     ) as action:
         script = textwrap.dedent(
             """
@@ -184,13 +184,18 @@ def perform_isolated_build(build_info, package_venv, session, config):
             )
         )
         config.distdir.ensure_dir()
-        result = action.popen([package_venv.envconfig.envpython, "-c", script], returnout=True)
+        result = package_venv._pcall(
+            [package_venv.envconfig.envpython, "-c", script],
+            returnout=True,
+            action=action,
+            cwd=session.config.setupdir,
+        )
         return config.distdir.join(result.split("\n")[-2])
 
 
 def get_build_requires(build_info, package_venv, session):
     with session.newaction(
-        package_venv, "get build requires", package_venv.envconfig.envdir
+        package_venv, "get-build-requires", package_venv.envconfig.envdir
     ) as action:
         script = textwrap.dedent(
             """
@@ -204,5 +209,10 @@ def get_build_requires(build_info, package_venv, session):
                 build_info.backend_module, build_info.backend_object, "sdist"
             )
         ).strip()
-        result = action.popen([package_venv.envconfig.envpython, "-c", script], returnout=True)
+        result = package_venv._pcall(
+            [package_venv.envconfig.envpython, "-c", script],
+            returnout=True,
+            action=action,
+            cwd=session.config.setupdir,
+        )
         return json.loads(result.split("\n")[-2])
diff --git a/src/tox/session.py b/src/tox/session.py
index 62dbba46..95716060 100644
--- a/src/tox/session.py
+++ b/src/tox/session.py
@@ -20,6 +20,7 @@ import py
 import tox
 from tox.config import parseconfig
 from tox.result import ResultLog
+from tox.util import set_os_env_var
 from tox.venv import VirtualEnv
 
 
@@ -43,7 +44,8 @@ def cmdline(args=None):
 def main(args):
     try:
         config = prepare(args)
-        retcode = Session(config).runcommand()
+        with set_os_env_var("TOX_WORK_DIR", config.toxworkdir):
+            retcode = build_session(config).runcommand()
         if retcode is None:
             retcode = 0
         raise SystemExit(retcode)
@@ -55,6 +57,10 @@ def main(args):
         raise SystemExit(1)
 
 
+def build_session(config):
+    return Session(config)
+
+
 def show_help(config):
     tw = py.io.TerminalWriter()
     tw.write(config._parser._format_help())
diff --git a/src/tox/util.py b/src/tox/util.py
new file mode 100644
index 00000000..731b7ecb
--- /dev/null
+++ b/src/tox/util.py
@@ -0,0 +1,16 @@
+import os
+from contextlib import contextmanager
+
+
+@contextmanager
+def set_os_env_var(env_var_name, value):
+    """Set an environment variable with unrolling once the context exists"""
+    prev_value = os.environ.get(env_var_name)
+    try:
+        os.environ[env_var_name] = str(value)
+        yield
+    finally:
+        if prev_value is None:
+            del os.environ[env_var_name]
+        else:
+            os.environ[env_var_name] = prev_value
diff --git a/src/tox/venv.py b/src/tox/venv.py
index dfee8c3d..8358cd1f 100755
--- a/src/tox/venv.py
+++ b/src/tox/venv.py
@@ -447,6 +447,7 @@ class VirtualEnv(object):
         action=None,
         redirect=True,
         ignore_ret=False,
+        returnout=False,
     ):
         # construct environment variables
         os.environ.pop("VIRTUALENV_PYTHON", None)
@@ -461,7 +462,9 @@ class VirtualEnv(object):
             args = prepend_shebang_interpreter(args)
 
         cwd.ensure(dir=1)  # ensure the cwd exists
-        return action.popen(args, cwd=cwd, env=env, redirect=redirect, ignore_ret=ignore_ret)
+        return action.popen(
+            args, cwd=cwd, env=env, redirect=redirect, ignore_ret=ignore_ret, returnout=returnout
+        )
 
 
 def getdigest(path):
diff --git a/tox.ini b/tox.ini
index 8b45a8f5..53311495 100644
--- a/tox.ini
+++ b/tox.ini
@@ -21,7 +21,7 @@ passenv = http_proxy https_proxy no_proxy SSL_CERT_FILE TOXENV CI TRAVIS TRAVIS_
 deps =
 extras = testing
 changedir = {toxinidir}/tests
-commands = pytest {posargs:--cov="{envsitepackagesdir}/tox" --cov-config="{toxinidir}/tox.ini"  --timeout=180 . -n {env:PYTEST_XDIST_PROC_NR:auto} --junitxml={toxworkdir}/test-results.{envname}.xml}
+commands = pytest {posargs:--cov="{envsitepackagesdir}/tox" --cov-config="{toxinidir}/tox.ini"  --timeout=180 . -n {env:PYTEST_XDIST_PROC_NR:auto}}
 
 [testenv:docs]
 description = invoke sphinx-build to build the HTML docs


Test patch:
diff --git a/tests/unit/session/test_session.py b/tests/unit/session/test_session.py
index 0e93f588..565728d5 100644
--- a/tests/unit/session/test_session.py
+++ b/tests/unit/session/test_session.py
@@ -1,8 +1,11 @@
+import os
 import re
 import uuid
+from threading import Thread
 
 import pytest
 
+import tox
 from tox.exception import MissingDependency, MissingDirectory
 
 
@@ -210,3 +213,94 @@ def test_venv_filter_match_some_some_active(venv_filter_project, monkeypatch):
         "py36-diffcov",
         "py36-diffcov-extra",
     ]
+
+
+@pytest.fixture()
+def popen_env_test(initproj, cmd, monkeypatch):
+    def func(tox_env, isolated_build):
+        files = {
+            "tox.ini": """
+               [tox]
+               isolated_build = {}
+               [testenv:{}]
+               commands = python -c "print('ok')"
+               """.format(
+                "True" if isolated_build else "False", tox_env
+            )
+        }
+        if isolated_build:
+            files[
+                "pyproject.toml"
+            ] = """
+                [build-system]
+                requires = ["setuptools >= 35.0.2", "setuptools_scm >= 2.0.0, <3"]
+                build-backend = 'setuptools.build_meta'
+                """
+        initproj("env_var_test", filedefs=files)
+
+        class IsolatedResult(object):
+            def __init__(self):
+                self.popens = []
+                self.cwd = None
+
+        res = IsolatedResult()
+
+        class EnvironmentTestRun(Thread):
+            """we wrap this invocation into a thread to avoid modifying in any way the
+             current threads environment variable (e.g. on failure of this test incorrect teardown)
+             """
+
+            def run(self):
+                prev_build = tox.session.build_session
+
+                def build_session(config):
+                    res.session = prev_build(config)
+                    res._popen = res.session.popen
+                    monkeypatch.setattr(res.session, "popen", popen)
+                    return res.session
+
+                monkeypatch.setattr(tox.session, "build_session", build_session)
+
+                def popen(cmd, **kwargs):
+                    activity_id = res.session._actions[-1].id
+                    activity_name = res.session._actions[-1].activity
+                    try:
+                        ret = res._popen(cmd, **kwargs)
+                    except tox.exception.InvocationError as exception:
+                        ret = exception
+                    finally:
+                        res.popens.append(
+                            (activity_id, activity_name, kwargs.get("env"), ret, cmd)
+                        )
+                    return ret
+
+                res.result = cmd("-e", tox_env)
+                res.cwd = os.getcwd()
+
+        thread = EnvironmentTestRun()
+        thread.start()
+        thread.join()
+        return res
+
+    yield func
+
+
+@pytest.mark.network
+def test_tox_env_var_flags_inserted_non_isolated(popen_env_test):
+    res = popen_env_test("py", False)
+    assert_popen_env(res)
+
+
+@pytest.mark.network
+def test_tox_env_var_flags_inserted_isolated(popen_env_test):
+    res = popen_env_test("py", True)
+    assert_popen_env(res)
+
+
+def assert_popen_env(res):
+    assert res.result.ret == 0, res.result.out
+    for tox_id, _, env, __, ___ in res.popens:
+        assert env["TOX_WORK_DIR"] == os.path.join(res.cwd, ".tox")
+        if tox_id != "tox":
+            assert env["TOX_ENV_NAME"] == tox_id
+            assert env["TOX_ENV_DIR"] == os.path.join(res.cwd, ".tox", tox_id)
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 8aae0a51..0ed8a3e5 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -899,7 +899,11 @@ class TestConfigTestEnv:
         assert envconfig.usedevelop is False
         assert envconfig.ignore_errors is False
         assert envconfig.envlogdir == envconfig.envdir.join("log")
-        assert list(envconfig.setenv.definitions.keys()) == ["PYTHONHASHSEED"]
+        assert set(envconfig.setenv.definitions.keys()) == {
+            "PYTHONHASHSEED",
+            "TOX_ENV_NAME",
+            "TOX_ENV_DIR",
+        }
         hashseed = envconfig.setenv["PYTHONHASHSEED"]
         assert isinstance(hashseed, str)
         # The following line checks that hashseed parses to an integer.
@@ -2060,7 +2064,7 @@ class TestHashseedOption:
     def test_noset(self, newconfig):
         args = ["--hashseed", "noset"]
         envconfig = self._get_envconfig(newconfig, args=args)
-        assert not envconfig.setenv.definitions
+        assert set(envconfig.setenv.definitions.keys()) == {"TOX_ENV_DIR", "TOX_ENV_NAME"}
 
     def test_noset_with_setenv(self, newconfig):
         tox_ini = """
diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py
new file mode 100644
index 00000000..bdd36ea2
--- /dev/null
+++ b/tests/unit/test_util.py
@@ -0,0 +1,22 @@
+import os
+
+from tox.util import set_os_env_var
+
+
+def test_set_os_env_var_clean_env(monkeypatch):
+    monkeypatch.delenv("ENV", raising=False)
+    with set_os_env_var("ENV", "a"):
+        assert os.environ["ENV"] == "a"
+    assert "ENV" not in os.environ
+
+
+def test_set_os_env_var_exist_env(monkeypatch):
+    monkeypatch.setenv("ENV", "b")
+    with set_os_env_var("ENV", "a"):
+        assert os.environ["ENV"] == "a"
+    assert os.environ["ENV"] == "b"
+
+
+def test_set_os_env_var_non_str():
+    with set_os_env_var("ENV", 1):
+        assert os.environ["ENV"] == "1"