Repository: theacodes/nox (python)

Issue:
Exclude combinations from parametrized matrix builds
I'm using the `@parametrize` decorator to create a build matrix, but i've got certain combinations I wish to exclude.

right now i'm using a little escape hatch

```python
if valid_combination(*deps):
    run_tests(session, *deps)
else:
    session.skip("unsupported combination")
```

would be nice if Nox could provide an API for adding or removing 'cells' from the matrix (similarly to what you can do with github actions)

Pull request description:
Allow nox.parametrize to select the session Python
Fixes #392
Supercedes #404


Patch:
diff --git a/docs/config.rst b/docs/config.rst
index a8da73d..d64d2f8 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -339,6 +339,44 @@ Produces these sessions when running ``nox --list``:
     * tests(mysql, new)
 
 
+Parametrizing the session Python
+--------------------------------
+
+You can use parametrization to select the Python interpreter for a session.
+These two examples are equivalent:
+
+.. code-block:: python
+
+    @nox.session
+    @nox.parametrize("python", ["3.6", "3.7", "3.8"])
+    def tests(session):
+        ...
+
+    @nox.session(python=["3.6", "3.7", "3.8"])
+    def tests(session):
+        ...
+
+The first form can be useful if you need to exclude some combinations of Python
+versions with other parameters. For example, you may want to test against
+multiple versions of a dependency, but the latest version doesn't run on older
+Pythons:
+
+.. code-block:: python
+
+    @nox.session
+    @nox.parametrize(
+        "python,dependency",
+        [
+            (python, dependency)
+            for python in ("3.6", "3.7", "3.8")
+            for dependency in ("1.0", "2.0")
+            if (python, dependency) != ("3.6", "2.0")
+        ],
+    )
+    def tests(session, dependency):
+        ...
+
+
 The session object
 ------------------
 
diff --git a/nox/_decorators.py b/nox/_decorators.py
index 03878de..b04e48a 100644
--- a/nox/_decorators.py
+++ b/nox/_decorators.py
@@ -1,5 +1,6 @@
 import copy
 import functools
+import inspect
 import types
 from typing import Any, Callable, Dict, Iterable, List, Optional, cast
 
@@ -66,20 +67,35 @@ class Func(FunctionDecorator):
 
 class Call(Func):
     def __init__(self, func: Func, param_spec: "Param") -> None:
+        call_spec = param_spec.call_spec
+        session_signature = "({})".format(param_spec)
+
+        # Determine the Python interpreter for the session using either @session
+        # or @parametrize. For backwards compatibility, we only use a "python"
+        # parameter in @parametrize if the session function does not expect it
+        # as a normal argument, and if the @session decorator does not already
+        # specify `python`.
+
+        python = func.python
+        if python is None and "python" in call_spec:
+            signature = inspect.signature(func.func)
+            if "python" not in signature.parameters:
+                python = call_spec.pop("python")
+
         super().__init__(
             func,
-            func.python,
+            python,
             func.reuse_venv,
             None,
             func.venv_backend,
             func.venv_params,
             func.should_warn,
         )
-        self.param_spec = param_spec
-        self.session_signature = "({})".format(param_spec)
+        self.call_spec = call_spec
+        self.session_signature = session_signature
 
     def __call__(self, *args: Any, **kwargs: Any) -> Any:
-        kwargs.update(self.param_spec.call_spec)
+        kwargs.update(self.call_spec)
         return super().__call__(*args, **kwargs)
 
     @classmethod
diff --git a/nox/manifest.py b/nox/manifest.py
index 1334fa9..8265de2 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import argparse
+import ast
 import collections.abc
 import itertools
 from collections import OrderedDict
@@ -133,21 +134,26 @@ class Manifest:
         queue = []
         for session_name in specified_sessions:
             for session in self._queue:
-                if session_name == session.name or session_name in set(
-                    session.signatures
-                ):
+                if _normalized_session_match(session_name, session):
                     queue.append(session)
-
         self._queue = queue
 
         # If a session was requested and was not found, complain loudly.
         all_sessions = set(
-            itertools.chain(
-                [x.name for x in self._all_sessions if x.name],
-                *[x.signatures for x in self._all_sessions],
+            map(
+                _normalize_arg,
+                (
+                    itertools.chain(
+                        [x.name for x in self._all_sessions if x.name],
+                        *[x.signatures for x in self._all_sessions],
+                    )
+                ),
             )
         )
-        missing_sessions = set(specified_sessions) - all_sessions
+        normalized_specified_sessions = [
+            _normalize_arg(session_name) for session_name in specified_sessions
+        ]
+        missing_sessions = set(normalized_specified_sessions) - all_sessions
         if missing_sessions:
             raise KeyError("Sessions not found: {}".format(", ".join(missing_sessions)))
 
@@ -344,4 +350,24 @@ def _null_session_func_(session: Session) -> None:
     session.skip("This session had no parameters available.")
 
 
+def _normalized_session_match(session_name: str, session: SessionRunner) -> bool:
+    """Checks if session_name matches session."""
+    if session_name == session.name or session_name in session.signatures:
+        return True
+    for name in session.signatures:
+        equal_rep = _normalize_arg(session_name) == _normalize_arg(name)
+        if equal_rep:
+            return True
+    # Exhausted
+    return False
+
+
+def _normalize_arg(arg: str) -> Union[str]:
+    """Normalize arg for comparison."""
+    try:
+        return str(ast.dump(ast.parse(arg)))
+    except (TypeError, SyntaxError):
+        return arg
+
+
 _null_session_func = Func(_null_session_func_, python=False)
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 0e3bbca..78322e0 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -226,18 +226,13 @@ class CondaEnv(ProcessEnv):
             )
             return False
 
-        cmd = [
-            "conda",
-            "create",
-            "--yes",
-            "--prefix",
-            self.location,
-            # Ensure the pip package is installed.
-            "pip",
-        ]
+        cmd = ["conda", "create", "--yes", "--prefix", self.location]
 
         cmd.extend(self.venv_params)
 
+        # Ensure the pip package is installed.
+        cmd.append("pip")
+
         if self.interpreter:
             python_dep = "python={}".format(self.interpreter)
         else:
@@ -313,12 +308,19 @@ class VirtualEnv(ProcessEnv):
         """Deletes any existing virtual environment"""
         if os.path.exists(self.location):
             if self.reuse_existing:
+                self._check_reused_environment()
                 return False
             else:
                 shutil.rmtree(self.location)
 
         return True
 
+    def _check_reused_environment(self) -> None:
+        """Check if reused environment type is the same."""
+        with open(os.path.join(self.location, "pyvenv.cfg")) as fp:
+            old_env = "virtualenv" if "virtualenv" in fp.read() else "venv"
+        assert old_env == self.venv_or_virtualenv
+
     @property
     def _resolved_interpreter(self) -> str:
         """Return the interpreter, appropriately resolved for the platform.
@@ -394,12 +396,25 @@ class VirtualEnv(ProcessEnv):
     def create(self) -> bool:
         """Create the virtualenv or venv."""
         if not self._clean_location():
-            logger.debug(
-                "Re-using existing virtual environment at {}.".format(
-                    self.location_name
-                )
+            original = nox.command.run(
+                [self._resolved_interpreter, "-c", "import sys; print(sys.prefix)"],
+                silent=True,
             )
-            return False
+            created = nox.command.run(
+                [
+                    os.path.join(self.location, "bin", "python"),
+                    "-c",
+                    "import sys; print(sys.real_prefix)",
+                ],
+                silent=True,
+            )
+            if original == created:
+                logger.debug(
+                    "Re-using existing virtual environment at {}.".format(
+                        self.location_name
+                    )
+                )
+                return False
 
         if self.venv_or_virtualenv == "virtualenv":
             cmd = [sys.executable, "-m", "virtualenv", self.location]


Test patch:
diff --git a/tests/test__parametrize.py b/tests/test__parametrize.py
index d130174..79fc2e5 100644
--- a/tests/test__parametrize.py
+++ b/tests/test__parametrize.py
@@ -15,7 +15,7 @@
 from unittest import mock
 
 import pytest
-from nox import _decorators, _parametrize
+from nox import _decorators, _parametrize, parametrize, session
 
 
 @pytest.mark.parametrize(
@@ -242,3 +242,69 @@ def test_generate_calls_ids():
     f.assert_called_with(foo=1)
     calls[1]()
     f.assert_called_with(foo=2)
+
+
+def test_generate_calls_session_python():
+    called_with = []
+
+    @session
+    @parametrize("python,dependency", [("3.8", "0.9"), ("3.9", "0.9"), ("3.9", "1.0")])
+    def f(session, dependency):
+        called_with.append((session, dependency))
+
+    calls = _decorators.Call.generate_calls(f, f.parametrize)
+
+    assert len(calls) == 3
+
+    assert calls[0].python == "3.8"
+    assert calls[1].python == "3.9"
+    assert calls[2].python == "3.9"
+
+    assert calls[0].session_signature == "(python='3.8', dependency='0.9')"
+    assert calls[1].session_signature == "(python='3.9', dependency='0.9')"
+    assert calls[2].session_signature == "(python='3.9', dependency='1.0')"
+
+    session_ = ()
+
+    calls[0](session_)
+    calls[1](session_)
+    calls[2](session_)
+
+    assert len(called_with) == 3
+
+    assert called_with[0] == (session_, "0.9")
+    assert called_with[1] == (session_, "0.9")
+    assert called_with[2] == (session_, "1.0")
+
+
+def test_generate_calls_python_compatibility():
+    called_with = []
+
+    @session
+    @parametrize("python,dependency", [("3.8", "0.9"), ("3.9", "0.9"), ("3.9", "1.0")])
+    def f(session, python, dependency):
+        called_with.append((session, python, dependency))
+
+    calls = _decorators.Call.generate_calls(f, f.parametrize)
+
+    assert len(calls) == 3
+
+    assert calls[0].python is None
+    assert calls[1].python is None
+    assert calls[2].python is None
+
+    assert calls[0].session_signature == "(python='3.8', dependency='0.9')"
+    assert calls[1].session_signature == "(python='3.9', dependency='0.9')"
+    assert calls[2].session_signature == "(python='3.9', dependency='1.0')"
+
+    session_ = ()
+
+    calls[0](session_)
+    calls[1](session_)
+    calls[2](session_)
+
+    assert len(called_with) == 3
+
+    assert called_with[0] == (session_, "3.8", "0.9")
+    assert called_with[1] == (session_, "3.9", "0.9")
+    assert called_with[2] == (session_, "3.9", "1.0")
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 35eb775..9fc0578 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -22,6 +22,8 @@ from nox.manifest import (
     WARN_PYTHONS_IGNORED,
     KeywordLocals,
     Manifest,
+    _normalize_arg,
+    _normalized_session_match,
     _null_session_func,
 )
 
@@ -42,6 +44,22 @@ def create_mock_config():
     return cfg
 
 
+def test__normalize_arg():
+    assert _normalize_arg('test(foo="bar")') == _normalize_arg('test(foo="bar")')
+
+    # In the case of SyntaxError it should fallback to strng
+    assert (
+        _normalize_arg("datetime.datetime(1990; 2, 18),")
+        == "datetime.datetime(1990; 2, 18),"
+    )
+
+
+def test__normalized_session_match():
+    session_mock = mock.MagicMock()
+    session_mock.signatures = ['test(foo="bar")']
+    assert _normalized_session_match("test(foo='bar')", session_mock)
+
+
 def test_init():
     sessions = create_mock_sessions()
     manifest = Manifest(sessions, create_mock_config())
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index 75598d6..cb76a87 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -181,6 +181,18 @@ def test_condaenv_create(make_conda):
     assert dir_.join("test.txt").check()
 
 
+@pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.")
+def test_condaenv_create_with_params(make_conda):
+    venv, dir_ = make_conda(venv_params=["--verbose"])
+    venv.create()
+    if IS_WINDOWS:
+        assert dir_.join("python.exe").check()
+        assert dir_.join("Scripts", "pip.exe").check()
+    else:
+        assert dir_.join("bin", "python").check()
+        assert dir_.join("bin", "pip").check()
+
+
 @pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.")
 def test_condaenv_create_interpreter(make_conda):
     venv, dir_ = make_conda(interpreter="3.7")
@@ -244,6 +256,9 @@ def test__clean_location(monkeypatch, make_one):
 
     # Don't re-use existing, but doesn't currently exist.
     # Should return True indicating that the venv needs to be created.
+    monkeypatch.setattr(
+        nox.virtualenv.VirtualEnv, "_check_reused_environment", mock.MagicMock()
+    )
     monkeypatch.delattr(nox.virtualenv.shutil, "rmtree")
     assert not dir_.check()
     assert venv._clean_location()
@@ -289,7 +304,7 @@ def test_bin_windows(make_one):
     assert dir_.join("Scripts").strpath == venv.bin
 
 
-def test_create(make_one):
+def test_create(monkeypatch, make_one):
     venv, dir_ = make_one()
     venv.create()
 
@@ -312,10 +327,26 @@ def test_create(make_one):
     dir_.ensure("test.txt")
     assert dir_.join("test.txt").check()
     venv.reuse_existing = True
+    monkeypatch.setattr(nox.virtualenv.nox.command, "run", mock.MagicMock())
     venv.create()
     assert dir_.join("test.txt").check()
 
 
+def test_create_check_interpreter(make_one, monkeypatch, tmpdir):
+    cmd_mock = mock.MagicMock(
+        side_effect=["python-1", "python-1", "python-2", "python-3", "python-4"]
+    )
+    monkeypatch.setattr(nox.virtualenv.nox.command, "run", cmd_mock)
+    test_dir = tmpdir.mkdir("pytest")
+    fp = test_dir.join("pyvenv.cfg")
+    fp.write("virtualenv")
+    venv, dir_ = make_one()
+    venv.reuse_existing = True
+    venv.location = test_dir.strpath
+    assert not venv.create()
+    assert venv.create()
+
+
 def test_create_venv_backend(make_one):
     venv, dir_ = make_one(venv=True)
     venv.create()