Repository: wntrblm/nox
Pull request #712 (2023-05-14 15:25:13)

action: use multiple interpreters features from 'actions/setup-python@v4'
The nox action can be simplified now that setup-python allows to setup multiple interpreters natively.

This also adds support for `~` version specifier (e.g. `~3.12.0-0`) which mostly should not be necessary as setup-python now supports `allow-prereleases` which transforms `3.12` into `~3.12.0-0` when searching for CPython 3.12.
The `~` specifier might be interesting for those who want a minimum patch version, e.g. `~3.11.1` <=> `>=3.11.1 <3.12`.


Patch:
diff --git a/.github/action_helper.py b/.github/action_helper.py
index b5326a6..e6ab5d9 100644
--- a/.github/action_helper.py
+++ b/.github/action_helper.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import json
 import sys
 
 
@@ -11,10 +12,12 @@ def filter_version(version: str) -> str:
         version_ = version[5:]
     elif version.startswith("pypy"):
         version_ = version[4:]
+    elif version.startswith("~"):
+        version_ = version[1:]
     else:
         version_ = version
 
-    # remove extra specifier e.g. "3.12-dev" => "3.12"
+    # remove extra specifier e.g. "3.12-dev" => "3.12", "~3.12.0-0" => "3.12"
     version_ = version_.split("-")[0]
 
     version_parts = version_.split(".")
@@ -56,18 +59,13 @@ def setup_action(input_: str) -> None:
     if "3.11" in cpython_versions_filtered:
         index = cpython_versions_filtered.index("3.11")
         index = versions.index(cpython_versions[index])
-        cpython_310 = versions.pop(index)
-        versions.append(cpython_310)
+        cpython_nox = versions.pop(index)
+        versions.append(cpython_nox)
     else:
         # add this to install nox
         versions.append("3.11")
 
-    if len(versions) > 20:
-        raise ValueError(f"too many interpreters to install: {len(versions)} > 20")
-
-    print(f"interpreter_count={len(versions)}")
-    for i, version in enumerate(versions):
-        print(f"interpreter_{i}={version}")
+    print(f"interpreters={json.dumps(versions)}")
 
 
 if __name__ == "__main__":
diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
index 094d287..d3e1208 100644
--- a/.github/workflows/action.yml
+++ b/.github/workflows/action.yml
@@ -24,6 +24,7 @@ jobs:
             windows-2022,
             macos-11,
             macos-12,
+            macos-13,
           ]
     steps:
       - uses: actions/checkout@v3
@@ -35,5 +36,5 @@ jobs:
       - uses: actions/checkout@v3
       - uses: ./
         with:
-          python-versions: "2.7.18, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, pypy-2.7, pypy-3.7, pypy-3.8, pypy-3.9-v7.3.9"
+          python-versions: "2.7.18, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, pypy-2.7, pypy-3.7, pypy-3.8, pypy-3.9-v7.3.9"
       - run: nox --non-interactive --error-on-missing-interpreter --session github_actions_all_tests
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f61c57c..37bb980 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -30,8 +30,8 @@ repos:
     hooks:
       - id: pyproject-fmt
 
-  - repo: https://github.com/charliermarsh/ruff-pre-commit
-    rev: v0.0.249
+  - repo: https://github.com/astral-sh/ruff-pre-commit
+    rev: v0.0.255
     hooks:
       - id: ruff
         args: ["--fix", "--show-fixes"]
diff --git a/action.yml b/action.yml
index 63c36c2..cfac6c9 100644
--- a/action.yml
+++ b/action.yml
@@ -25,86 +25,11 @@ runs:
       shell: bash
 
     - uses: actions/setup-python@v4
+      id: allpython
       with:
-        python-version: "${{ steps.helper.outputs.interpreter_0 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 0 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_1 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 1 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_2 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 2 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_3 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 3 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_4 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 4 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_5 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 5 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_6 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 6 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_7 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 7 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_8 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 8 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_9 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 9 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_10 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 10 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_11 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 11 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_12 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 12 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_13 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 13 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_14 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 14 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_15 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 15 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_16 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 16 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_17 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 17 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_18 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 18 }}
-    - uses: actions/setup-python@v4
-      with:
-        python-version: "${{ steps.helper.outputs.interpreter_19 }}"
-      if: ${{ steps.helper.outputs.interpreter_count > 19 }}
+        python-version: "${{ join(fromJSON(steps.helper.outputs.interpreters), '\n') }}"
+        allow-prereleases: true
 
     - name: "Install nox"
-      run: pipx install --python "${{ steps.localpython.outputs.python-path }}" '${{ github.action_path }}'
+      run: pipx install --python "${{ steps.allpython.outputs.python-path }}" '${{ github.action_path }}'
       shell: bash
diff --git a/docs/cookbook.rst b/docs/cookbook.rst
index 491f548..26f45d5 100644
--- a/docs/cookbook.rst
+++ b/docs/cookbook.rst
@@ -131,3 +131,35 @@ Even more so with a sprinkling of Nox:
         session.run("git", "push", "--tags", external=True)
 
 Now a simple ``nox -s release -- patch`` will automate your release (provided you have Bump2Version set up to change your files). This is especially powerful if you have a CI/CD pipeline set up!
+
+
+Generating a matrix with GitHub Actions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Nox knows what sessions it needs to run. Why not tell GitHub Actions what jobs to run dynamically? Using the ``--json`` flag and a bit of json processing, it's easy:
+
+.. code-block:: yaml
+
+    jobs:
+      generate-jobs:
+        runs-on: ubuntu-latest
+        outputs:
+          session: ${{ steps.set-matrix.outputs.session }}
+        steps:
+        - uses: actions/checkout@v3
+        - uses: wntrblm/nox@main
+        - id: set-matrix
+          shell: bash
+          run: echo session=$(nox --json -l | jq -c '[.[].session]') | tee --append $GITHUB_OUTPUT
+      checks:
+        name: Session ${{ matrix.session }}
+        needs: [generate-jobs]
+        runs-on: ubuntu-latest
+        strategy:
+          fail-fast: false
+          matrix:
+            session: ${{ fromJson(needs.generate-jobs.outputs.session) }}
+        steps:
+        - uses: actions/checkout@v3
+        - uses: wntrblm/nox@main
+        - run: nox -s "${{ matrix.session }}"
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 097cf66..ac76a54 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -42,7 +42,6 @@ If you want to run ``nox`` within `GitHub Actions`_, you can use the ``wntrblm/n
     # setup nox only for a given list of python versions
     # Limitations:
     # - Version specifiers shall be supported by actions/setup-python
-    # - You can specify up-to 20 versions
     # - There can only be one "major.minor" per interpreter i.e. "3.7.0, 3.7.1" is invalid
     # this uses version 2022.8.7 but any Nox tag will work here
     - uses: wntrblm/nox@2022.8.7
diff --git a/nox/_decorators.py b/nox/_decorators.py
index 72636ae..1d4c4ea 100644
--- a/nox/_decorators.py
+++ b/nox/_decorators.py
@@ -66,6 +66,7 @@ class Func(FunctionDecorator):
         self.func = func
         self.python = python
         self.reuse_venv = reuse_venv
+        self.name = name
         self.venv_backend = venv_backend
         self.venv_params = venv_params
         self.should_warn = should_warn or {}
diff --git a/nox/_options.py b/nox/_options.py
index f761277..8d13c84 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -66,11 +66,11 @@ options.add_groups(
 )
 
 
-def _sessions_and_keywords_merge_func(
+def _sessions_merge_func(
     key: str, command_args: argparse.Namespace, noxfile_args: argparse.Namespace
 ) -> list[str]:
-    """Only return the Noxfile value for sessions/keywords if neither sessions
-    or keywords are specified on the command-line.
+    """Only return the Noxfile value for sessions/keywords if neither sessions,
+    keywords or tags are specified on the command-line.
 
     Args:
         key (str): This function is used for both the "sessions" and "keywords"
@@ -80,7 +80,11 @@ def _sessions_and_keywords_merge_func(
             command-line.
         noxfile_Args (_option_set.Namespace): The options specified in the
             Noxfile."""
-    if not command_args.sessions and not command_args.keywords:
+    if (
+        not command_args.sessions
+        and not command_args.keywords
+        and not command_args.tags
+    ):
         return getattr(noxfile_args, key)  # type: ignore[no-any-return]
     return getattr(command_args, key)  # type: ignore[no-any-return]
 
@@ -177,7 +181,7 @@ def _color_finalizer(value: bool, args: argparse.Namespace) -> bool:
     if args.forcecolor:
         return True
 
-    if args.nocolor:
+    if args.nocolor or "NO_COLOR" in os.environ:
         return False
 
     return sys.stdout.isatty()
@@ -280,7 +284,7 @@ options.add_options(
         "--session",
         group=options.groups["sessions"],
         noxfile=True,
-        merge_func=functools.partial(_sessions_and_keywords_merge_func, "sessions"),
+        merge_func=functools.partial(_sessions_merge_func, "sessions"),
         nargs="*",
         default=default_env_var_list_factory("NOXSESSION"),
         help="Which sessions to run. By default, all sessions will run.",
@@ -303,7 +307,7 @@ options.add_options(
         "--keywords",
         group=options.groups["sessions"],
         noxfile=True,
-        merge_func=functools.partial(_sessions_and_keywords_merge_func, "keywords"),
+        merge_func=functools.partial(_sessions_merge_func, "keywords"),
         help="Only run sessions that match the given expression.",
     ),
     _option_set.Option(
@@ -312,6 +316,7 @@ options.add_options(
         "--tags",
         group=options.groups["sessions"],
         noxfile=True,
+        merge_func=functools.partial(_sessions_merge_func, "tags"),
         nargs="*",
         help="Only run sessions with the given tags.",
     ),
diff --git a/nox/command.py b/nox/command.py
index ca3dd35..9398a8d 100644
--- a/nox/command.py
+++ b/nox/command.py
@@ -52,16 +52,16 @@ def which(program: str, paths: list[str] | None) -> str:
     raise CommandFailed(f"Program {program} not found")
 
 
-def _clean_env(env: dict[str, str] | None) -> dict[str, str] | None:
-    if env is None:
-        return None
-
+def _clean_env(env: dict[str, str] | None) -> dict[str, str]:
     clean_env = {}
 
     # Ensure systemroot is passed down, otherwise Windows will explode.
-    clean_env["SYSTEMROOT"] = os.environ.get("SYSTEMROOT", "")
+    if sys.platform == "win32":
+        clean_env["SYSTEMROOT"] = os.environ.get("SYSTEMROOT", "")
+
+    if env is not None:
+        clean_env.update(env)
 
-    clean_env.update(env)
     return clean_env
 
 
diff --git a/nox/registry.py b/nox/registry.py
index 376c55e..35cb389 100644
--- a/nox/registry.py
+++ b/nox/registry.py
@@ -85,8 +85,11 @@ def session_decorator(
     if python is None:
         python = py
 
-    fn = Func(func, python, reuse_venv, name, venv_backend, venv_params, tags=tags)
-    _REGISTRY[name or func.__name__] = fn
+    final_name = name or func.__name__
+    fn = Func(
+        func, python, reuse_venv, final_name, venv_backend, venv_params, tags=tags
+    )
+    _REGISTRY[final_name] = fn
     return fn
 
 
diff --git a/nox/sessions.py b/nox/sessions.py
index d86c36f..dc1aa7e 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -270,7 +270,11 @@ class Session:
             raise nox.command.CommandFailed() from e
 
     def run(
-        self, *args: str, env: Mapping[str, str] | None = None, **kwargs: Any
+        self,
+        *args: str,
+        env: dict[str, str] | None = None,
+        include_outer_env: bool = True,
+        **kwargs: Any,
     ) -> Any | None:
         """Run a command.
 
@@ -337,6 +341,10 @@ class Session:
         :param env: A dictionary of environment variables to expose to the
             command. By default, all environment variables are passed.
         :type env: dict or None
+        :param include_outer_env: Boolean parameter that determines if the
+            environment variables from the nox invocation environment should
+            be passed to the command. ``True`` by default.
+        :type include_outer_env: bool
         :param bool silent: Silence command output, unless the command fails.
             If ``True``, returns the command output (unless the command fails).
             ``False`` by default.
@@ -373,10 +381,19 @@ class Session:
             logger.info(f"Skipping {args[0]} run, as --install-only is set.")
             return None
 
-        return self._run(*args, env=env, **kwargs)
+        return self._run(
+            *args,
+            env=env,
+            include_outer_env=include_outer_env,
+            **kwargs,
+        )
 
     def run_always(
-        self, *args: str, env: Mapping[str, str] | None = None, **kwargs: Any
+        self,
+        *args: str,
+        env: dict[str, str] | None = None,
+        include_outer_env: bool = True,
+        **kwargs: Any,
     ) -> Any | None:
         """Run a command **always**.
 
@@ -394,6 +411,10 @@ class Session:
         :param env: A dictionary of environment variables to expose to the
             command. By default, all environment variables are passed.
         :type env: dict or None
+        :param include_outer_env: Boolean parameter that determines if the
+            environment variables from the nox invocation environment should
+            be passed to the command. ``True`` by default.
+        :type include_outer_env: bool
         :param bool silent: Silence command output, unless the command fails.
             ``False`` by default.
         :param success_codes: A list of return codes that are considered
@@ -426,10 +447,19 @@ class Session:
         if not args:
             raise ValueError("At least one argument required to run_always().")
 
-        return self._run(*args, env=env, **kwargs)
+        return self._run(
+            *args,
+            env=env,
+            include_outer_env=include_outer_env,
+            **kwargs,
+        )
 
     def _run(
-        self, *args: str, env: Mapping[str, str] | None = None, **kwargs: Any
+        self,
+        *args: str,
+        env: dict[str, str] | None = None,
+        include_outer_env: bool = True,
+        **kwargs: Any,
     ) -> Any:
         """Like run(), except that it runs even if --install-only is provided."""
         # Legacy support - run a function given.
@@ -437,12 +467,11 @@ class Session:
             return self._run_func(args[0], args[1:], kwargs)
 
         # Combine the env argument with our virtualenv's env vars.
-        if env is not None:
+        if include_outer_env:
             overlay_env = env
             env = self.env.copy()
-            env.update(overlay_env)
-        else:
-            env = self.env
+            if overlay_env is not None:
+                env.update(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/noxfile.py b/noxfile.py
index dd0917d..3d2a65e 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -149,6 +149,7 @@ def github_actions_default_tests(session: nox.Session) -> None:
         "3.9",
         "3.10",
         "3.11",
+        "3.12",
         "pypy3.7",
         "pypy3.8",
         "pypy3.9",


Test patch:
diff --git a/tests/test_action_helper.py b/tests/test_action_helper.py
index a331652..983c944 100644
--- a/tests/test_action_helper.py
+++ b/tests/test_action_helper.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import json
 import sys
 from pathlib import Path
 
@@ -14,6 +15,7 @@ VALID_VERSIONS = {
     "3.9-dev": "3.9",
     "3.10": "3.10",
     "3.11": "3.11",
+    "~3.12.0-0": "3.12",
     "pypy-3.7": "3.7",
     "pypy-3.8-v7.3.9": "3.8",
     "pypy-3.9": "3.9",
@@ -43,45 +45,24 @@ def test_filter_version_invalid_minor():
 
 VALID_VERSION_LISTS = {
     "3.7, 3.8, 3.9, 3.10, 3.11, pypy-3.7, pypy-3.8, pypy-3.9": [
-        "interpreter_count=8",
-        "interpreter_0=pypy-3.7",
-        "interpreter_1=pypy-3.8",
-        "interpreter_2=pypy-3.9",
-        "interpreter_3=3.7",
-        "interpreter_4=3.8",
-        "interpreter_5=3.9",
-        "interpreter_6=3.10",
-        "interpreter_7=3.11",
+        "pypy-3.7",
+        "pypy-3.8",
+        "pypy-3.9",
+        "3.7",
+        "3.8",
+        "3.9",
+        "3.10",
+        "3.11",
     ],
-    "": [
-        "interpreter_count=1",
-        "interpreter_0=3.11",
-    ],
-    "3.11.4": [
-        "interpreter_count=1",
-        "interpreter_0=3.11.4",
-    ],
-    "3.9-dev,pypy3.9-nightly": [
-        "interpreter_count=3",
-        "interpreter_0=pypy3.9-nightly",
-        "interpreter_1=3.9-dev",
-        "interpreter_2=3.11",
-    ],
-    "3.11, 3.10, 3.9, 3.8": [
-        "interpreter_count=4",
-        "interpreter_0=3.10",
-        "interpreter_1=3.9",
-        "interpreter_2=3.8",
-        "interpreter_3=3.11",
-    ],
-    ",".join(f"3.{minor}" for minor in range(20)): (
-        ["interpreter_count=20"]
-        + [
-            f"interpreter_{i}=3.{minor}"
-            for i, minor in enumerate(minor_ for minor_ in range(20) if minor_ != 11)
-        ]
-        + ["interpreter_19=3.11"]
-    ),
+    "": ["3.11"],
+    "~3.12.0-0": ["~3.12.0-0", "3.11"],
+    "3.11.4": ["3.11.4"],
+    "3.9-dev,pypy3.9-nightly": ["pypy3.9-nightly", "3.9-dev", "3.11"],
+    "3.11, 3.10, 3.9, 3.8": ["3.10", "3.9", "3.8", "3.11"],
+    ",".join(f"3.{minor}" for minor in range(20)): [
+        f"3.{minor}"
+        for i, minor in enumerate(minor_ for minor_ in range(20) if minor_ != 11)
+    ] + ["3.11"],
 }
 
 
@@ -90,7 +71,8 @@ def test_setup_action(capsys, version_list):
     setup_action(version_list)
     captured = capsys.readouterr()
     lines = captured.out.splitlines()
-    assert lines == VALID_VERSION_LISTS[version_list]
+    ordered_versions = VALID_VERSION_LISTS[version_list]
+    assert lines == [f"interpreters={json.dumps(ordered_versions)}"]
 
 
 def test_setup_action_multiple_pypy():
@@ -112,8 +94,3 @@ def test_setup_action_multiple_cpython():
         ),
     ):
         setup_action("3.10, 3.10.4")
-
-
-def test_setup_action_too_many_interpreters():
-    with pytest.raises(ValueError, match=r"too many interpreters to install: 21 > 20"):
-        setup_action(",".join(f"3.{minor}" for minor in range(21)))
diff --git a/tests/test_command.py b/tests/test_command.py
index e58077a..68f370a 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -144,6 +144,7 @@ def test_run_env_unicode():
     assert "123" in result
 
 
+@mock.patch("sys.platform", "win32")
 def test_run_env_systemroot():
     systemroot = os.environ.setdefault("SYSTEMROOT", "sigil")
 
@@ -181,7 +182,7 @@ def test_run_path_existent(tmp_path: Path):
     with mock.patch("nox.command.popen") as mock_command:
         mock_command.return_value = (0, "")
         nox.command.run([executable_name], silent=True, paths=[str(tmp_path)])
-        mock_command.assert_called_with([str(executable)], env=None, silent=True)
+        mock_command.assert_called_with([str(executable)], env=mock.ANY, silent=True)
 
 
 def test_run_external_warns(tmpdir, caplog):
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index dffd283..27bd8b8 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -273,6 +273,37 @@ class TestSession:
         )
         assert result.strip() == "1 3"
 
+    def test_by_default_all_invocation_env_vars_are_passed(self):
+        session, runner = self.make_session_and_runner()
+        runner.venv.env["I_SHOULD_BE_INCLUDED"] = "happy"
+        runner.venv.env["I_SHOULD_BE_INCLUDED_TOO"] = "happier"
+        runner.venv.env["EVERYONE_SHOULD_BE_INCLUDED_TOO"] = "happiest"
+        result = session.run(
+            sys.executable,
+            "-c",
+            "import os; print(os.environ)",
+            silent=True,
+        )
+        assert "happy" in result
+        assert "happier" in result
+        assert "happiest" in result
+
+    def test_no_included_invocation_env_vars_are_passed(self):
+        session, runner = self.make_session_and_runner()
+        runner.venv.env["I_SHOULD_NOT_BE_INCLUDED"] = "sad"
+        runner.venv.env["AND_NEITHER_SHOULD_I"] = "unhappy"
+        result = session.run(
+            sys.executable,
+            "-c",
+            "import os; print(os.environ)",
+            env={"I_SHOULD_BE_INCLUDED": "happy"},
+            include_outer_env=False,
+            silent=True,
+        )
+        assert "sad" not in result
+        assert "unhappy" not in result
+        assert "happy" in result
+
     def test_run_external_not_a_virtualenv(self):
         # Non-virtualenv sessions should always allow external programs.
         session, runner = self.make_session_and_runner()
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index c6bdbfe..99ffae3 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -315,6 +315,31 @@ def test_filter_manifest_tags_not_found(tags, caplog):
     assert "Tag selection caused no sessions to be selected." in caplog.text
 
 
+def test_merge_sessions_and_tags():
+    @nox.session(tags=["foobar"])
+    def test():
+        pass
+
+    @nox.session(tags=["foobar"])
+    def bar():
+        pass
+
+    config = _options.options.namespace(
+        noxfile=os.path.join(RESOURCES, "noxfile_options.py"),
+        sessions=None,
+        pythons=(),
+        posargs=[],
+        tags=["foobar"],
+    )
+
+    nox_module = tasks.load_nox_module(config)
+    tasks.merge_noxfile_options(nox_module, config)
+    manifest = Manifest({"test": test, "bar": bar}, config)
+    return_value = tasks.filter_manifest(manifest, config)
+    assert return_value is manifest
+    assert len(manifest) == 2
+
+
 def test_honor_list_request_noop():
     config = _options.options.namespace(list_sessions=False)
     manifest = {"thing": mock.sentinel.THING}
diff --git a/tests/test_tox_to_nox.py b/tests/test_tox_to_nox.py
index c7b5485..4419d1a 100644
--- a/tests/test_tox_to_nox.py
+++ b/tests/test_tox_to_nox.py
@@ -19,7 +19,7 @@ import textwrap
 
 import pytest
 
-from nox import tox_to_nox
+tox_to_nox = pytest.importorskip("nox.tox_to_nox")
 
 
 @pytest.fixture
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index c2b3da1..ddc0172 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -32,7 +32,7 @@ import nox.virtualenv
 IS_WINDOWS = nox.virtualenv._SYSTEM == "Windows"
 HAS_CONDA = shutil.which("conda") is not None
 RAISE_ERROR = "RAISE_ERROR"
-VIRTUALENV_VERSION = virtualenv.version.version
+VIRTUALENV_VERSION = virtualenv.__version__
 
 
 class TextProcessResult(NamedTuple):