Repository: theacodes/nox (python)

Issue:
Session.run finds incorrect Python 
**Describe the bug**
`session.run('python', ..., ....)` can not find the *virtual* environments python. 

**How to reproduce**
Here is my noxfile.py. The last line is the where the issue occurs.  There are **no** issues in creating a conda environment, installing conda packages, installing pip packages, and performing the pytest. I have verified it all and it completely works as expected (installing and using binaries and scripts in the virtual environment). The issue is only the last command which could only find a default python on the system (external). I have diagnosed the issue and it is explained below.

```python
import nox
import os
VENV = 'conda' if os.name == 'nt' else None
NAME = 'win' if os.name == 'nt' else 'linux'

@nox.session(
    python=['3.6', '3.7'],
    reuse_venv=True,
    venv_backend=VENV,
    name='test_' + NAME)
def test(session):
    if VENV == 'conda':
        session.conda_install('--channel=conda-forge', 'shapely')
    session.install('-e', '.[dev]')
    session.run('pytest')
    session.run('python', 'setup.py', 'sdist', 'bdist_wheel')
```

**Fix**

The issue is here:  https://github.com/theacodes/nox/blob/ac0992332a266f85152db8a5252b1fdf9818eb75/nox/command.py#L82

where `cmd` is `python` and  `path` will be to the virtual environments `Script` directory. Here is a picture of what `path` is:
![scripts](https://user-images.githubusercontent.com/6628085/66622663-90f41000-ebb6-11e9-93a9-80962aacd700.PNG)

As you can see there is no python here. The correct python is here:

![python](https://user-images.githubusercontent.com/6628085/66622736-dc0e2300-ebb6-11e9-9dd3-7237cb01bb08.PNG)


The change I performed to fix this issue is highlighted in the images. I changed this:
https://github.com/theacodes/nox/blob/ac0992332a266f85152db8a5252b1fdf9818eb75/nox/command.py#L36

to this:

```
if path:
        full_path = py.path.local.sysfind(program, paths=[path, os.path.dirname(path)])
```

Now everything works as expected. I am not sure if this is the best way to fix but it works well for me. If this is acceptable I can do a pull request if you would like.

Thanks for your great work!

  



Pull request description:
Fixed the default paths for conda on windows where the `python.exe` found was not the correct one
Changelog (should I commit it in the changelog file ?)

 - Virtual environments now have a `bin_paths` attribute instead of a single `bin`. `command.run` and `which` both have their `path` argument renamed `paths` and supporting a list of paths, to handle it. 

 - Fixed the default paths for conda on windows where the `python.exe` found was not the correct one. Fixes #256


Patch:
diff --git a/nox/command.py b/nox/command.py
index 766481e..b2e99ce 100644
--- a/nox/command.py
+++ b/nox/command.py
@@ -14,7 +14,7 @@
 
 import os
 import sys
-from typing import Any, Iterable, Optional, Sequence, Union
+from typing import Any, Iterable, Optional, Sequence, Union, List
 
 import py
 from nox.logger import logger
@@ -29,12 +29,12 @@ class CommandFailed(Exception):
         self.reason = reason
 
 
-def which(program: str, path: Optional[str]) -> str:
+def which(program: str, paths: Optional[List[str]]) -> str:
     """Finds the full path to an executable."""
     full_path = None
 
-    if path:
-        full_path = py.path.local.sysfind(program, paths=[path])
+    if paths:
+        full_path = py.path.local.sysfind(program, paths=paths)
 
     if full_path:
         return full_path.strpath
@@ -66,7 +66,7 @@ def run(
     *,
     env: Optional[dict] = None,
     silent: bool = False,
-    path: Optional[str] = None,
+    paths: Optional[List[str]] = None,
     success_codes: Optional[Iterable[int]] = None,
     log: bool = True,
     external: bool = False,
@@ -80,12 +80,14 @@ def run(
     cmd, args = args[0], args[1:]
     full_cmd = "{} {}".format(cmd, " ".join(args))
 
-    cmd_path = which(cmd, path)
+    cmd_path = which(cmd, paths)
 
     if log:
         logger.info(full_cmd)
 
-        is_external_tool = path is not None and not cmd_path.startswith(path)
+        is_external_tool = paths is not None and not any(
+            cmd_path.startswith(path) for path in paths
+        )
         if is_external_tool:
             if external == "error":
                 logger.error(
diff --git a/nox/sessions.py b/nox/sessions.py
index bac226f..c35b0c0 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -131,10 +131,16 @@ class Session:
         """The python version passed into ``@nox.session``."""
         return self._runner.func.python
 
+    @property
+    def bin_paths(self) -> Optional[List[str]]:
+        """The bin directories for the virtualenv."""
+        return self.virtualenv.bin_paths
+
     @property
     def bin(self) -> Optional[str]:
-        """The bin directory for the virtualenv."""
-        return self.virtualenv.bin
+        """The first bin directory for the virtualenv."""
+        paths = self.bin_paths
+        return paths[0] if paths is not None else None
 
     def create_tmp(self) -> str:
         """Create, and return, a temporary directory."""
@@ -279,7 +285,7 @@ class Session:
             kwargs["external"] = True
 
         # Run a shell command.
-        return nox.command.run(args, env=env, path=self.bin, **kwargs)
+        return nox.command.run(args, env=env, paths=self.bin_paths, **kwargs)
 
     def conda_install(self, *args: str, **kwargs: Any) -> None:
         """Install invokes `conda install`_ to install packages inside of the
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index fea0238..5824bfc 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -17,7 +17,7 @@ import platform
 import re
 import shutil
 import sys
-from typing import Any, Mapping, Optional, Tuple, Union
+from typing import Any, Mapping, Optional, Tuple, Union, List
 
 import nox.command
 import py
@@ -48,8 +48,8 @@ class ProcessEnv:
     # Special programs that aren't included in the environment.
     allowed_globals = ()  # type: _typing.ClassVar[Tuple[Any, ...]]
 
-    def __init__(self, bin: None = None, env: Mapping[str, str] = None) -> None:
-        self._bin = bin
+    def __init__(self, bin_paths: None = None, env: Mapping[str, str] = None) -> None:
+        self._bin_paths = bin_paths
         self.env = os.environ.copy()
 
         if env is not None:
@@ -58,12 +58,20 @@ class ProcessEnv:
         for key in _BLACKLISTED_ENV_VARS:
             self.env.pop(key, None)
 
-        if self.bin:
-            self.env["PATH"] = os.pathsep.join([self.bin, self.env.get("PATH", "")])
+        if self.bin_paths:
+            self.env["PATH"] = os.pathsep.join(
+                self.bin_paths + [self.env.get("PATH", "")]
+            )
+
+    @property
+    def bin_paths(self) -> Optional[List[str]]:
+        return self._bin_paths
 
     @property
     def bin(self) -> Optional[str]:
-        return self._bin
+        """The first bin directory for the virtualenv."""
+        paths = self.bin_paths
+        return paths[0] if paths is not None else None
 
     def create(self) -> bool:
         raise NotImplementedError("ProcessEnv.create should be overwritten in subclass")
@@ -191,12 +199,13 @@ class CondaEnv(ProcessEnv):
     _clean_location = _clean_location
 
     @property
-    def bin(self) -> str:
+    def bin_paths(self) -> List[str]:
         """Returns the location of the conda env's bin folder."""
+        # see https://docs.anaconda.com/anaconda/user-guide/tasks/integration/python-path/#examples
         if _SYSTEM == "Windows":
-            return os.path.join(self.location, "Scripts")
+            return [self.location, os.path.join(self.location, "Scripts")]
         else:
-            return os.path.join(self.location, "bin")
+            return [os.path.join(self.location, "bin")]
 
     def create(self) -> bool:
         """Create the conda env."""
@@ -339,12 +348,12 @@ class VirtualEnv(ProcessEnv):
         raise self._resolved
 
     @property
-    def bin(self) -> str:
+    def bin_paths(self) -> List[str]:
         """Returns the location of the virtualenv's bin folder."""
         if _SYSTEM == "Windows":
-            return os.path.join(self.location, "Scripts")
+            return [os.path.join(self.location, "Scripts")]
         else:
-            return os.path.join(self.location, "bin")
+            return [os.path.join(self.location, "bin")]
 
     def create(self) -> bool:
         """Create the virtualenv or venv."""


Test patch:
diff --git a/tests/test_command.py b/tests/test_command.py
index 3124f90..45919dd 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -122,7 +122,7 @@ def test_run_path_nonexistent():
     result = nox.command.run(
         [PYTHON, "-c", "import sys; print(sys.executable)"],
         silent=True,
-        path="/non/existent",
+        paths=["/non/existent"],
     )
 
     assert "/non/existent" not in result
@@ -135,14 +135,14 @@ def test_run_path_existent(tmpdir, monkeypatch):
 
     with mock.patch("nox.command.popen") as mock_command:
         mock_command.return_value = (0, "")
-        nox.command.run(["testexc"], silent=True, path=tmpdir.strpath)
+        nox.command.run(["testexc"], silent=True, paths=[tmpdir.strpath])
         mock_command.assert_called_with([executable.strpath], env=None, silent=True)
 
 
 def test_run_external_warns(tmpdir, caplog):
     caplog.set_level(logging.WARNING)
 
-    nox.command.run([PYTHON, "--version"], silent=True, path=tmpdir.strpath)
+    nox.command.run([PYTHON, "--version"], silent=True, paths=[tmpdir.strpath])
 
     assert "external=True" in caplog.text
 
@@ -151,7 +151,7 @@ def test_run_external_silences(tmpdir, caplog):
     caplog.set_level(logging.WARNING)
 
     nox.command.run(
-        [PYTHON, "--version"], silent=True, path=tmpdir.strpath, external=True
+        [PYTHON, "--version"], silent=True, paths=[tmpdir.strpath], external=True
     )
 
     assert "external=True" not in caplog.text
@@ -162,7 +162,7 @@ def test_run_external_raises(tmpdir, caplog):
 
     with pytest.raises(nox.command.CommandFailed):
         nox.command.run(
-            [PYTHON, "--version"], silent=True, path=tmpdir.strpath, external="error"
+            [PYTHON, "--version"], silent=True, paths=[tmpdir.strpath], external="error"
         )
 
     assert "external=True" in caplog.text
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 367314b..6afecaf 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -73,7 +73,7 @@ class TestSession:
         )
         runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
         runner.venv.env = {}
-        runner.venv.bin = "/no/bin/for/you"
+        runner.venv.bin_paths = ["/no/bin/for/you"]
         return nox.sessions.Session(runner=runner), runner
 
     def test_create_tmp(self):
@@ -100,9 +100,17 @@ class TestSession:
         assert session.env is runner.venv.env
         assert session.posargs is runner.global_config.posargs
         assert session.virtualenv is runner.venv
-        assert session.bin is runner.venv.bin
+        assert session.bin_paths is runner.venv.bin_paths
+        assert session.bin is runner.venv.bin_paths[0]
         assert session.python is runner.func.python
 
+    def test_no_bin_paths(self):
+        session, runner = self.make_session_and_runner()
+
+        runner.venv.bin_paths = None
+        assert session.bin is None
+        assert session.bin_paths is None
+
     def test_virtualenv_as_none(self):
         session, runner = self.make_session_and_runner()
 
@@ -187,7 +195,7 @@ class TestSession:
             ("pip", "install", "spam"),
             env=mock.ANY,
             external=mock.ANY,
-            path=mock.ANY,
+            paths=mock.ANY,
             silent=mock.ANY,
         )
 
@@ -225,7 +233,7 @@ class TestSession:
             session.run(sys.executable, "--version")
 
         run.assert_called_once_with(
-            (sys.executable, "--version"), external=True, env=mock.ANY, path=None
+            (sys.executable, "--version"), external=True, env=mock.ANY, paths=None
         )
 
     def test_run_external_condaenv(self):
@@ -234,14 +242,17 @@ class TestSession:
         runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
         runner.venv.allowed_globals = ("conda",)
         runner.venv.env = {}
-        runner.venv.bin = "/path/to/env/bin"
+        runner.venv.bin_paths = ["/path/to/env/bin"]
         runner.venv.create.return_value = True
 
         with mock.patch("nox.command.run", autospec=True) as run:
             session.run("conda", "--version")
 
         run.assert_called_once_with(
-            ("conda", "--version"), external=True, env=mock.ANY, path="/path/to/env/bin"
+            ("conda", "--version"),
+            external=True,
+            env=mock.ANY,
+            paths=["/path/to/env/bin"],
         )
 
     def test_run_external_with_error_on_external_run(self):
@@ -256,7 +267,7 @@ class TestSession:
         session, runner = self.make_session_and_runner()
         runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
         runner.venv.env = {}
-        runner.venv.bin = "/path/to/env/bin"
+        runner.venv.bin_paths = ["/path/to/env/bin"]
 
         runner.global_config.error_on_external_run = True
 
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index 543d66f..8a8ed6d 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -120,7 +120,7 @@ def patch_sysfind(make_mocked_interpreter_path):
 
 def test_process_env_constructor():
     penv = nox.virtualenv.ProcessEnv()
-    assert not penv.bin
+    assert not penv.bin_paths
 
     penv = nox.virtualenv.ProcessEnv(env={"SIGIL": "123"})
     assert penv.env["SIGIL"] == "123"
@@ -186,7 +186,7 @@ def test_condaenv_create_interpreter(make_conda):
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
 def test_condaenv_bin_windows(make_conda):
     venv, dir_ = make_conda()
-    assert dir_.join("Scripts").strpath == venv.bin
+    assert [dir_.strpath, dir_.join("Scripts").strpath] == venv.bin_paths
 
 
 def test_constructor_defaults(make_one):
@@ -209,13 +209,16 @@ def test_env(monkeypatch, make_one):
     monkeypatch.setenv("SIGIL", "123")
     venv, _ = make_one()
     assert venv.env["SIGIL"] == "123"
-    assert venv.bin in venv.env["PATH"]
-    assert venv.bin not in os.environ["PATH"]
+    assert len(venv.bin_paths) == 1
+    assert venv.bin_paths[0] in venv.env["PATH"]
+    assert venv.bin_paths[0] not in os.environ["PATH"]
 
 
 def test_blacklisted_env(monkeypatch, make_one):
     monkeypatch.setenv("__PYVENV_LAUNCHER__", "meep")
     venv, _ = make_one()
+    assert len(venv.bin_paths) == 1
+    assert venv.bin_paths[0] == venv.bin
     assert "__PYVENV_LAUNCHER__" not in venv.bin
 
 
@@ -249,9 +252,12 @@ def test__clean_location(monkeypatch, make_one):
     assert venv._clean_location()
 
 
-def test_bin(make_one):
+def test_bin_paths(make_one):
     venv, dir_ = make_one()
 
+    assert len(venv.bin_paths) == 1
+    assert venv.bin_paths[0] == venv.bin
+
     if IS_WINDOWS:
         assert dir_.join("Scripts").strpath == venv.bin
     else:
@@ -261,6 +267,8 @@ def test_bin(make_one):
 @mock.patch("nox.virtualenv._SYSTEM", new="Windows")
 def test_bin_windows(make_one):
     venv, dir_ = make_one()
+    assert len(venv.bin_paths) == 1
+    assert venv.bin_paths[0] == venv.bin
     assert dir_.join("Scripts").strpath == venv.bin