Repository: wntrblm/nox
Pull request #812 (2024-04-09 07:17:27)

feat: support None to remove envvars
Followup to #804. Setting `env={"NOCOLOR": None}` will unset the value if it exists in the outer environment.


henryiii: I got a thumbs up, anyone want to do a review? Nice to have an approval. ;)

Patch:
diff --git a/nox/command.py b/nox/command.py
index b1f544d..4882e3a 100644
--- a/nox/command.py
+++ b/nox/command.py
@@ -55,17 +55,15 @@ def which(program: str | os.PathLike[str], paths: Sequence[str] | None) -> str:
     raise CommandFailed(f"Program {program} not found")
 
 
-def _clean_env(env: Mapping[str, str] | None = None) -> dict[str, str] | None:
+def _clean_env(env: Mapping[str, str | None] | None = None) -> dict[str, str] | None:
     if env is None:
         return None
 
-    clean_env: dict[str, str] = {}
+    clean_env = {k: v for k, v in env.items() if v is not None}
 
     # Ensure systemroot is passed down, otherwise Windows will explode.
     if sys.platform == "win32":
-        clean_env["SYSTEMROOT"] = os.environ.get("SYSTEMROOT", "")
-
-    clean_env.update(env)
+        clean_env.setdefault("SYSTEMROOT", os.environ.get("SYSTEMROOT", ""))
 
     return clean_env
 
@@ -77,7 +75,7 @@ def _shlex_join(args: Sequence[str | os.PathLike[str]]) -> str:
 def run(
     args: Sequence[str | os.PathLike[str]],
     *,
-    env: Mapping[str, str] | None = None,
+    env: Mapping[str, str | None] | None = None,
     silent: bool = False,
     paths: Sequence[str] | None = None,
     success_codes: Iterable[int] | None = None,
diff --git a/nox/sessions.py b/nox/sessions.py
index 1544c3f..b9d2669 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -283,7 +283,7 @@ class Session:
     def run(
         self,
         *args: str | os.PathLike[str],
-        env: Mapping[str, str] | None = None,
+        env: Mapping[str, str | None] | None = None,
         include_outer_env: bool = True,
         **kwargs: Any,
     ) -> Any | None:
@@ -350,7 +350,9 @@ class Session:
             print("Current Git commit is", out.strip())
 
         :param env: A dictionary of environment variables to expose to the
-            command. By default, all environment variables are passed.
+            command. By default, all environment variables are passed. You
+            can block an environment variable from the outer environment by
+            setting it to None.
         :type env: dict or None
         :param include_outer_env: Boolean parameter that determines if the
             environment variables from the nox invocation environment should
@@ -406,7 +408,7 @@ class Session:
     def run_install(
         self,
         *args: str | os.PathLike[str],
-        env: Mapping[str, str] | None = None,
+        env: Mapping[str, str | None] | None = None,
         include_outer_env: bool = True,
         **kwargs: Any,
     ) -> Any | None:
@@ -474,7 +476,7 @@ class Session:
     def run_always(
         self,
         *args: str | os.PathLike[str],
-        env: Mapping[str, str] | None = None,
+        env: Mapping[str, str | None] | None = None,
         include_outer_env: bool = True,
         **kwargs: Any,
     ) -> Any | None:
@@ -490,7 +492,7 @@ class Session:
     def _run(
         self,
         *args: str | os.PathLike[str],
-        env: Mapping[str, str] | None = None,
+        env: Mapping[str, str | None] | None = None,
         include_outer_env: bool = True,
         **kwargs: Any,
     ) -> Any:
@@ -501,10 +503,8 @@ class Session:
 
         # Combine the env argument with our virtualenv's env vars.
         if include_outer_env:
-            overlay_env = env
-            env = self.env.copy()
-            if overlay_env is not None:
-                env.update(overlay_env)
+            overlay_env = env or {}
+            env = {**self.env, **overlay_env}
 
         # If --error-on-external-run is specified, error on external programs.
         if self._runner.global_config.error_on_external_run:
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index bf93f9f..90ee085 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -34,7 +34,12 @@ from nox.logger import logger
 # Problematic environment variables that are stripped from all commands inside
 # of a virtualenv. See https://github.com/theacodes/nox/issues/44
 _BLACKLISTED_ENV_VARS = frozenset(
-    ["PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"]
+    [
+        "PIP_RESPECT_VIRTUALENV",
+        "PIP_REQUIRE_VIRTUALENV",
+        "__PYVENV_LAUNCHER__",
+        "UV_SYSTEM_PYTHON",
+    ]
 )
 _SYSTEM = platform.system()
 
@@ -78,15 +83,13 @@ class ProcessEnv(abc.ABC):
         self, bin_paths: None = None, env: Mapping[str, str | None] | None = None
     ) -> None:
         self._bin_paths = bin_paths
-        self.env = os.environ.copy()
         self._reused = False
-        env = env or {}
 
-        for k, v in env.items():
-            if v is None:
-                self.env.pop(k, None)
-            else:
-                self.env[k] = v
+        # Filter envs now so `.env` is dict[str, str] (easier to use)
+        # even though .command's env supports None.
+        env = env or {}
+        env = {**os.environ, **env}
+        self.env = {k: v for k, v in env.items() if v is not None}
 
         for key in _BLACKLISTED_ENV_VARS:
             self.env.pop(key, None)


Test patch:
diff --git a/tests/test_command.py b/tests/test_command.py
index 68f370a..9a50093 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -144,6 +144,19 @@ def test_run_env_unicode():
     assert "123" in result
 
 
+def test_run_env_remove(monkeypatch):
+    monkeypatch.setenv("EMPTY", "notempty")
+    nox.command.run(
+        [PYTHON, "-c", 'import os; assert "EMPTY" in os.environ'],
+        silent=True,
+    )
+    nox.command.run(
+        [PYTHON, "-c", 'import os; assert "EMPTY" not in os.environ'],
+        silent=True,
+        env={"EMPTY": None},
+    )
+
+
 @mock.patch("sys.platform", "win32")
 def test_run_env_systemroot():
     systemroot = os.environ.setdefault("SYSTEMROOT", "sigil")
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index e720c61..2a105b8 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -271,14 +271,15 @@ class TestSession:
         session, runner = self.make_session_and_runner()
         runner.venv.env["A"] = "1"
         runner.venv.env["B"] = "2"
+        runner.venv.env["C"] = "4"
         result = session.run(
             sys.executable,
             "-c",
-            'import os; print(os.environ["A"], os.environ["B"])',
-            env={"B": "3"},
+            'import os; print(os.environ["A"], os.environ["B"], os.environ.get("C", "5"))',
+            env={"B": "3", "C": None},
             silent=True,
         )
-        assert result.strip() == "1 3"
+        assert result.strip() == "1 3 5"
 
     def test_by_default_all_invocation_env_vars_are_passed(self):
         session, runner = self.make_session_and_runner()