Repository: theacodes/nox (python)

Issue:
`conda_install` should automatically escape args when they contain version constraints
In `Session.conda_install` one reads this example:

```python
session.conda_install('--channel=conda-forge', 'dask==2.1.0')
```

Now consider that we tweak it very lightly: 

```python
session.conda_install('--channel=conda-forge', 'dask>2.0.0')
```

Running it, at least on windows, will 

 - not take into account the constraint ">2.0.0"
 - write a nice but completely useless file named "2.0.0" in the working dir, containing the installation log.

Even worse: reverting the sign (`'dask<2.0.0'`) yields an error:

```shell
Command conda install --yes --prefix C:\......\.nox\tests-2-7 --channel=conda-forge dask<2.0.0 failed with exit code 1:
The system cannot find the file specified.
Traceback (most recent call last):
  ...
  File "C:\_dev\python_ws\_Libs_OpenSource\nox\nox\sessions.py", line 293, in conda_install
    **kwargs
  File "C:\_dev\python_ws\_Libs_OpenSource\nox\nox\sessions.py", line 246, in _run
    return nox.command.run(args, env=env, paths=self.bin_paths, **kwargs)
  File "C:\_dev\python_ws\_Libs_OpenSource\nox\nox\command.py", line 125, in run
    raise CommandFailed("Returned code {}".format(return_code))
nox.command.CommandFailed: Returned code 1
```

This is of course because the `>` and `<` operators are handled by the shell and should be escaped. I'll try to propose some fix.



Pull request description:
`conda_install` and `install` args are now automatically double-quoted when needed.
Changelog (let me know if I should commit it somewhere):

 - `conda_install` ~~and `install`~~ args are now automatically double-quoted when they contain a `<` or a `>`. Fixes #311


Patch:
diff --git a/nox/sessions.py b/nox/sessions.py
index 643f21c..e88f3e5 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -70,6 +70,36 @@ def _normalize_path(envdir: str, path: Union[str, bytes]) -> str:
     return full_path
 
 
+def _dblquote_pkg_install_args(args: Tuple[str, ...]) -> Tuple[str, ...]:
+    """Double-quote package install arguments in case they contain '>' or '<' symbols"""
+
+    # routine used to handle a single arg
+    def _dblquote_pkg_install_arg(pkg_req_str: str) -> str:
+        # sanity check: we need an even number of double-quotes
+        if pkg_req_str.count('"') % 2 != 0:
+            raise ValueError(
+                "ill-formated argument with odd number of quotes: %s" % pkg_req_str
+            )
+
+        if "<" in pkg_req_str or ">" in pkg_req_str:
+            if pkg_req_str[0] == '"' and pkg_req_str[-1] == '"':
+                # already double-quoted string
+                return pkg_req_str
+            else:
+                # need to double-quote string
+                if '"' in pkg_req_str:
+                    raise ValueError(
+                        "Cannot escape requirement string: %s" % pkg_req_str
+                    )
+                return '"%s"' % pkg_req_str
+        else:
+            # no dangerous char: no need to double-quote string
+            return pkg_req_str
+
+    # double-quote all args that need to be and return the result
+    return tuple(_dblquote_pkg_install_arg(a) for a in args)
+
+
 class _SessionQuit(Exception):
     pass
 
@@ -332,6 +362,9 @@ class Session:
         if not args:
             raise ValueError("At least one argument required to install().")
 
+        # Escape args that should be (conda-specific; pip install does not need this)
+        args = _dblquote_pkg_install_args(args)
+
         if "silent" not in kwargs:
             kwargs["silent"] = True
 


Test patch:
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 1da6f88..afd691b 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -302,6 +302,22 @@ class TestSession:
         with pytest.raises(ValueError, match="arg"):
             session.conda_install()
 
+    def test_conda_install_bad_args_odd_nb_double_quotes(self):
+        session, runner = self.make_session_and_runner()
+        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+        runner.venv.location = "./not/a/location"
+
+        with pytest.raises(ValueError, match="odd number of quotes"):
+            session.conda_install('a"a')
+
+    def test_conda_install_bad_args_cannot_escape(self):
+        session, runner = self.make_session_and_runner()
+        runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+        runner.venv.location = "./not/a/location"
+
+        with pytest.raises(ValueError, match="Cannot escape"):
+            session.conda_install('a"o"<a')
+
     def test_conda_install_not_a_condaenv(self):
         session, runner = self.make_session_and_runner()
 
@@ -348,7 +364,12 @@ class TestSession:
                 external="error",
             )
 
-    def test_conda_install_non_default_kwargs(self):
+    @pytest.mark.parametrize(
+        "version_constraint",
+        ["no", "yes", "already_dbl_quoted"],
+        ids="version_constraint={}".format,
+    )
+    def test_conda_install_non_default_kwargs(self, version_constraint):
         runner = nox.sessions.SessionRunner(
             name="test",
             signatures=["test"],
@@ -366,8 +387,18 @@ class TestSession:
 
         session = SessionNoSlots(runner=runner)
 
+        if version_constraint == "no":
+            pkg_requirement = passed_arg = "urllib3"
+        elif version_constraint == "yes":
+            pkg_requirement = "urllib3<1.25"
+            passed_arg = '"%s"' % pkg_requirement
+        elif version_constraint == "already_dbl_quoted":
+            pkg_requirement = passed_arg = '"urllib3<1.25"'
+        else:
+            raise ValueError(version_constraint)
+
         with mock.patch.object(session, "_run", autospec=True) as run:
-            session.conda_install("requests", "urllib3", silent=False)
+            session.conda_install("requests", pkg_requirement, silent=False)
             run.assert_called_once_with(
                 "conda",
                 "install",
@@ -375,12 +406,13 @@ class TestSession:
                 "--prefix",
                 "/path/to/conda/env",
                 "requests",
-                "urllib3",
+                # this will be double quoted if unquoted constraint is present
+                passed_arg,
                 silent=False,
                 external="error",
             )
 
-    def test_install_bad_args(self):
+    def test_install_bad_args_no_arg(self):
         session, _ = self.make_session_and_runner()
 
         with pytest.raises(ValueError, match="arg"):