Repository: theacodes/nox
Pull request #417 (2021-04-10 18:22:48)

Add normalized comparison to check sessions (#396)
First of all, thanks for this amazing tool, I started to use it recently (I come from the `tox` world)

This is a possible fix for (#396):

- Attempt to do the previous checks
- On fail, it will normalize arguments using ast (based on discussion)
- The comparison will be on ast representation of the strings
- Add unit tests



Patch:
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_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()