Repository: tox-dev/tox

Issue:
tox4: plugins need a way to modify a testenv's environment variables
tox-docker needs to set environment variables that will be present for running tests. I've tried this from `tox_run_before_commands`:

```python
tox_env.conf["set_env"].update_if_not_present(...)
```

but it looks like the caching in `ToxEnv` at https://github.com/tox-dev/tox/blob/rewrite/src/tox/tox_env/api.py#L287-L288 is preventing this from having any effect

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/tox-dev/tox/pull/2218?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) Report
> Merging [#2218](https://codecov.io/gh/tox-dev/tox/pull/2218?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) (2bc8d54) into [rewrite](https://codecov.io/gh/tox-dev/tox/commit/27733abf80ab52354d7a07e49e0618ffa8f37e7d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) (27733ab) will **increase** coverage by `0.13%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tox-dev/tox/pull/2218/graphs/tree.svg?width=650&height=150&src=pr&token=DYodAwDCZ5&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev)](https://codecov.io/gh/tox-dev/tox/pull/2218?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev)

```diff
@@             Coverage Diff              @@
##           rewrite     #2218      +/-   ##
============================================
+ Coverage    99.86%   100.00%   +0.13%     
============================================
  Files          162       161       -1     
  Lines         9358      9300      -58     
  Branches       960       937      -23     
============================================
- Hits          9345      9300      -45     
+ Misses           3         0       -3     
+ Partials        10         0      -10     
```

| Flag | Coverage Δ | |
|---|---|---|
| tests | `100.00% <100.00%> (+0.13%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/tox-dev/tox/pull/2218?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) | Coverage Δ | |
|---|---|---|
| [src/tox/config/set\_env.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9jb25maWcvc2V0X2Vudi5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/config/sets.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9jb25maWcvc2V0cy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/pytest.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9weXRlc3QucHk=) | `100.00% <100.00%> (ø)` | |
| [src/tox/tox\_env/api.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC90b3hfZW52L2FwaS5weQ==) | `100.00% <100.00%> (ø)` | |
| [tests/config/test\_set\_env.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-dGVzdHMvY29uZmlnL3Rlc3Rfc2V0X2Vudi5weQ==) | `100.00% <100.00%> (ø)` | |
| [tests/plugin/test\_plugin.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-dGVzdHMvcGx1Z2luL3Rlc3RfcGx1Z2luLnB5) | `100.00% <100.00%> (ø)` | |
| [src/tox/execute/request.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9leGVjdXRlL3JlcXVlc3QucHk=) | `100.00% <0.00%> (ø)` | |
| [src/tox/util/pep517/backend.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC91dGlsL3BlcDUxNy9iYWNrZW5kLnB5) | `100.00% <0.00%> (ø)` | |
| [src/tox/config/loader/str\_convert.py](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev#diff-c3JjL3RveC9jb25maWcvbG9hZGVyL3N0cl9jb252ZXJ0LnB5) | `100.00% <0.00%> (ø)` | |
| ... and [4 more](https://codecov.io/gh/tox-dev/tox/pull/2218/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tox-dev/tox/pull/2218?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tox-dev/tox/pull/2218?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev). Last update [27733ab...2bc8d54](https://codecov.io/gh/tox-dev/tox/pull/2218?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tox-dev).


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 137ee8b3..cb501bb8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,7 +24,7 @@ repos:
     hooks:
       - id: isort
   - repo: https://github.com/psf/black
-    rev: 21.8b0
+    rev: 21.9b0
     hooks:
       - id: black
         args:
diff --git a/docs/changelog/2215.feature.rst b/docs/changelog/2215.feature.rst
new file mode 100644
index 00000000..93d6e974
--- /dev/null
+++ b/docs/changelog/2215.feature.rst
@@ -0,0 +1,1 @@
+Allow plugins to update the :ref:`set_env` and change the :ref:`pass_env` configurations -- by :user:`gaborbernat`.
diff --git a/src/tox/config/set_env.py b/src/tox/config/set_env.py
index 39faad23..33cfae45 100644
--- a/src/tox/config/set_env.py
+++ b/src/tox/config/set_env.py
@@ -28,6 +28,7 @@ class SetEnv:
                 else:
                     self._raw[key] = value
         self._materialized: Dict[str, str] = {}
+        self.changed = False
 
     @staticmethod
     def _extract_key_value(line: str) -> Tuple[str, str]:
@@ -63,11 +64,12 @@ class SetEnv:
             self._raw.update(sub_raw)
             yield from sub_raw.keys()
 
-    def update_if_not_present(self, param: Mapping[str, str]) -> None:
+    def update(self, param: Mapping[str, str], *, override: bool = True) -> None:
         for key, value in param.items():
             # do not override something already set explicitly
-            if key not in self._raw and key not in self._materialized:
+            if override or (key not in self._raw and key not in self._materialized):
                 self._materialized[key] = value
+                self.changed = True
 
 
 __all__ = ("SetEnv",)
diff --git a/src/tox/config/sets.py b/src/tox/config/sets.py
index 25f9e8df..d4aeedc5 100644
--- a/src/tox/config/sets.py
+++ b/src/tox/config/sets.py
@@ -225,7 +225,7 @@ class EnvConfigSet(ConfigSet):
 
     def register_config(self) -> None:
         def set_env_post_process(values: SetEnv) -> SetEnv:
-            values.update_if_not_present(self.default_set_env_loader())
+            values.update(self.default_set_env_loader(), override=False)
             return values
 
         def set_env_factory(raw: object) -> SetEnv:
diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py
index e06e16d9..aed536e5 100644
--- a/src/tox/tox_env/api.py
+++ b/src/tox/tox_env/api.py
@@ -59,6 +59,7 @@ class ToxEnv(ABC):
         self._paths_private: List[Path] = []  #: a property holding the PATH environment variables
         self._hidden_outcomes: Optional[List[Outcome]] = []
         self._env_vars: Optional[Dict[str, str]] = None
+        self._env_vars_pass_env: List[str] = []
         self._suspended_out_err: Optional[OutErr] = None
         self._execute_statuses: Dict[int, ExecuteStatus] = {}
         self._interrupted = False
@@ -284,13 +285,14 @@ class ToxEnv(ABC):
 
     @property
     def _environment_variables(self) -> Dict[str, str]:
-        if self._env_vars is not None:
-            return self._env_vars
         pass_env: List[str] = self.conf["pass_env"]
-        result = self._load_pass_env(pass_env)
         set_env: SetEnv = self.conf["set_env"]
+        if self._env_vars_pass_env == pass_env and not set_env.changed and self._env_vars is not None:
+            return self._env_vars
+
+        result = self._load_pass_env(pass_env)
         # load/paths_env might trigger a load of the environment variables, set result here, returns current state
-        self._env_vars = result
+        self._env_vars, self._env_vars_pass_env, set_env.changed = result, pass_env, False
         # set PATH here in case setting and environment variable requires access to the environment variable PATH
         result["PATH"] = self._make_path()
         for key in set_env:


Test patch:
diff --git a/src/tox/pytest.py b/src/tox/pytest.py
index 34ffe24b..e16677a1 100644
--- a/src/tox/pytest.py
+++ b/src/tox/pytest.py
@@ -37,6 +37,7 @@ from tox.config.sets import EnvConfigSet
 from tox.execute.api import Execute, ExecuteInstance, ExecuteOptions, ExecuteStatus, Outcome
 from tox.execute.request import ExecuteRequest, shell_cmd
 from tox.execute.stream import SyncWrite
+from tox.plugin import manager
 from tox.report import LOGGER, OutErr
 from tox.run import run as tox_run
 from tox.run import setup_state as previous_setup_state
@@ -74,12 +75,19 @@ def ensure_logging_framework_not_altered() -> Iterator[None]:  # noqa: PT004
 def _disable_root_tox_py(request: SubRequest, mocker: MockerFixture) -> Iterator[None]:
     """unless this is a plugin test do not allow loading toxfile.py"""
     if request.node.get_closest_marker("plugin_test"):  # unregister inline plugin
-        from tox.plugin import manager
+        module, load_inline = None, manager._load_inline
 
-        inline_plugin = mocker.spy(manager, "_load_inline")
+        def _load_inline(path: Path) -> Optional[ModuleType]:  # register only on first run, and unregister at end
+            nonlocal module
+            if module is None:
+                module = load_inline(path)
+                return module
+            return None
+
+        mocker.patch.object(manager, "_load_inline", _load_inline)
         yield
-        if inline_plugin.spy_return is not None:  # pragma: no branch
-            manager.MANAGER.manager.unregister(inline_plugin.spy_return)
+        if module is not None:  # pragma: no branch
+            manager.MANAGER.manager.unregister(module)
     else:  # do not allow loading inline plugins
         mocker.patch("tox.plugin.inline._load_plugin", return_value=None)
         yield
@@ -605,7 +613,7 @@ def enable_pip_pypi_access_fixture(
     return previous_url
 
 
-def register_inline_plugin(mocker: MockerFixture, *args: Callable[..., Any]) -> None:  #
+def register_inline_plugin(mocker: MockerFixture, *args: Callable[..., Any]) -> None:
     frame_info = inspect.stack()[1]
     caller_module = inspect.getmodule(frame_info[0])
     assert caller_module is not None
diff --git a/tests/config/test_set_env.py b/tests/config/test_set_env.py
index ad9d3670..0694a71a 100644
--- a/tests/config/test_set_env.py
+++ b/tests/config/test_set_env.py
@@ -9,7 +9,7 @@ from tox.pytest import MonkeyPatch, ToxProjectCreator
 
 def test_set_env_explicit() -> None:
     set_env = SetEnv("\nA=1\nB = 2\nC= 3\nD= 4", "py", "py")
-    set_env.update_if_not_present({"E": "5 ", "F": "6"})
+    set_env.update({"E": "5 ", "F": "6"}, override=False)
 
     keys = list(set_env)
     assert keys == ["E", "F", "A", "B", "C", "D"]
diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py
index 90de3c23..bca24170 100644
--- a/tests/plugin/test_plugin.py
+++ b/tests/plugin/test_plugin.py
@@ -1,6 +1,8 @@
 import logging
+import os
 import sys
 from typing import List
+from unittest.mock import patch
 
 import pytest
 from pytest_mock import MockerFixture
@@ -134,3 +136,47 @@ def test_plugin_injects_invalid_python_run(tox_project: ToxProjectCreator, mocke
     result = project.run()
     result.assert_failed()
     assert "raise TypeError(raw)" in result.out
+
+
+def test_plugin_extend_pass_env(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None:
+    @impl
+    def tox_add_env_config(env_conf: EnvConfigSet, config: Config) -> None:
+        env_conf["pass_env"].append("MAGIC_*")
+
+    register_inline_plugin(mocker, tox_add_env_config)
+    ini = """
+    [testenv]
+    package=skip
+    commands=python -c 'import os; print(os.environ["MAGIC_1"]); print(os.environ["MAGIC_2"])'
+    """
+    project = tox_project({"tox.ini": ini})
+    with patch.dict(os.environ, {"MAGIC_1": "magic_1", "MAGIC_2": "magic_2"}):
+        result = project.run("r")
+    result.assert_success()
+    assert "magic_1" in result.out
+    assert "magic_2" in result.out
+
+    result_conf = project.run("c", "-e", "py", "-k", "pass_env")
+    result_conf.assert_success()
+    assert "MAGIC_*" in result_conf.out
+
+
+def test_plugin_extend_set_env(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None:
+    @impl
+    def tox_add_env_config(env_conf: EnvConfigSet, config: Config) -> None:
+        env_conf["set_env"].update({"MAGI_CAL": "magi_cal"})
+
+    register_inline_plugin(mocker, tox_add_env_config)
+    ini = """
+    [testenv]
+    package=skip
+    commands=python -c 'import os; print(os.environ["MAGI_CAL"])'
+    """
+    project = tox_project({"tox.ini": ini})
+    result = project.run("r")
+    result.assert_success()
+    assert "magi_cal" in result.out
+
+    result_conf = project.run("c", "-e", "py", "-k", "set_env")
+    result_conf.assert_success()
+    assert "MAGI_CAL=magi_cal" in result_conf.out