Repository: theacodes/nox (python)

Issue:
`CommandFailed` when using `conda_install` while being offline, even if env is fully setup
As opposed to `session.install`, that continues to work nicely when no internet connection is present **but** all required packages are already installed, `session.conda_install` fails miserably.

```
session.conda_install('pip')
conda install --yes --prefix C:\_dev\python_ws\my_project\.nox\lint-3-7 pip
Command conda install --yes --prefix C:\_dev\python_ws\my_project\.nox\lint-3-7 pip failed with exit code 1:
Collecting package metadata (current_repodata.json): ...working... failed
# >>>>>>>>>>>>>>>>>>>>>> ERROR REPORT <<<<<<<<<<<<<<<<<<<<<<
    Traceback (most recent call last):
      File "C:\Miniconda3\lib\site-packages\urllib3\connection.py", line 157, in _new_conn
        (self._dns_host, self.port), self.timeout, **extra_kw
      File "C:\Miniconda3\lib\site-packages\urllib3\util\connection.py", line 61, in create_connection
        for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM):
      File "C:\Miniconda3\lib\socket.py", line 748, in getaddrinfo
        for res in _socket.getaddrinfo(host, port, family, type, proto, flags):
    socket.gaierror: [Errno 11001] getaddrinfo failed
    
    During handling of the above exception, another exception occurred:
    
    Traceback (most recent call last):
      File "C:\Miniconda3\lib\site-packages\urllib3\connectionpool.py", line 672, in urlopen
        chunked=chunked,
      File "C:\Miniconda3\lib\site-packages\urllib3\connectionpool.py", line 376, in _make_request
        self._validate_conn(conn)
      File "C:\Miniconda3\lib\site-packages\urllib3\connectionpool.py", line 994, in _validate_conn
        conn.connect()
      File "C:\Miniconda3\lib\site-packages\urllib3\connection.py", line 334, in connect
        conn = self._new_conn()
      File "C:\Miniconda3\lib\site-packages\urllib3\connection.py", line 169, in _new_conn
        self, "Failed to establish a new connection: %s" % e
    urllib3.exceptions.NewConnectionError: <urllib3.connection.VerifiedHTTPSConnection object at 0x000001AD90D23F60>: Failed to establish a new connection: [Errno 11001] getaddrinfo failed
    
    During handling of the above exception, another exception occurred:
    
    Traceback (most recent call last):
      File "C:\Miniconda3\lib\site-packages\requests\adapters.py", line 449, in send
        timeout=timeout
      File "C:\Miniconda3\lib\site-packages\urllib3\connectionpool.py", line 760, in urlopen
        **response_kw
      File "C:\Miniconda3\lib\site-packages\urllib3\connectionpool.py", line 760, in urlopen
        **response_kw
      File "C:\Miniconda3\lib\site-packages\urllib3\connectionpool.py", line 760, in urlopen
        **response_kw
      File "C:\Miniconda3\lib\site-packages\urllib3\connectionpool.py", line 720, in urlopen
        method, url, error=e, _pool=self, _stacktrace=sys.exc_info()[2]
      File "C:\Miniconda3\lib\site-packages\urllib3\util\retry.py", line 436, in increment
        raise MaxRetryError(_pool, url, error or ResponseError(cause))
    urllib3.exceptions.MaxRetryError: HTTPSConnectionPool(host='repo.anaconda.com', port=443): Max retries exceeded with url: /pkgs/main/win-64/current_repodata.json (Caused by NewConnectionError('<urllib3.connection.VerifiedHTTPSConnection object at 0x000001AD90D23F60>: Failed to establish a new connection: [Errno 11001] getaddrinfo failed'))
    
    During handling of the above exception, another exception occurred:
    
    Traceback (most recent call last):
      File "C:\Miniconda3\lib\site-packages\conda\core\subdir_data.py", line 485, in fetch_repodata_remote_request
        timeout=timeout)
      File "C:\Miniconda3\lib\site-packages\requests\sessions.py", line 546, in get
        return self.request('GET', url, **kwargs)
      File "C:\Miniconda3\lib\site-packages\requests\sessions.py", line 533, in request
        resp = self.send(prep, **send_kwargs)
      File "C:\Miniconda3\lib\site-packages\requests\sessions.py", line 646, in send
        r = adapter.send(request, **kwargs)
      File "C:\Miniconda3\lib\site-packages\requests\adapters.py", line 516, in send
        raise ConnectionError(e, request=request)
    requests.exceptions.ConnectionError: HTTPSConnectionPool(host='repo.anaconda.com', port=443): Max retries exceeded with url: /pkgs/main/win-64/current_repodata.json (Caused by NewConnectionError('<urllib3.connection.VerifiedHTTPSConnection object at 0x000001AD90D23F60>: Failed to establish a new connection: [Errno 11001] getaddrinfo failed'))
    
    During handling of the above exception, another exception occurred:
    
    Traceback (most recent call last):
      File "C:\Miniconda3\lib\site-packages\conda\exceptions.py", line 1078, in __call__
        return func(*args, **kwargs)
      File "C:\Miniconda3\lib\site-packages\conda\cli\main.py", line 84, in _main
        exit_code = do_call(args, p)
      File "C:\Miniconda3\lib\site-packages\conda\cli\conda_argparse.py", line 82, in do_call
        exit_code = getattr(module, func_name)(args, parser)
      File "C:\Miniconda3\lib\site-packages\conda\cli\main_install.py", line 20, in execute
        install(args, parser, 'install')
      File "C:\Miniconda3\lib\site-packages\conda\cli\install.py", line 265, in install
        should_retry_solve=(_should_retry_unfrozen or repodata_fn != repodata_fns[-1]),
      File "C:\Miniconda3\lib\site-packages\conda\core\solve.py", line 117, in solve_for_transaction
        should_retry_solve)
      File "C:\Miniconda3\lib\site-packages\conda\core\solve.py", line 158, in solve_for_diff
        force_remove, should_retry_solve)
      File "C:\Miniconda3\lib\site-packages\conda\core\solve.py", line 262, in solve_final_state
        ssc = self._collect_all_metadata(ssc)
      File "C:\Miniconda3\lib\site-packages\conda\common\io.py", line 88, in decorated
        return f(*args, **kwds)
      File "C:\Miniconda3\lib\site-packages\conda\core\solve.py", line 415, in _collect_all_metadata
        index, r = self._prepare(prepared_specs)
      File "C:\Miniconda3\lib\site-packages\conda\core\solve.py", line 1004, in _prepare
        self.subdirs, prepared_specs, self._repodata_fn)
      File "C:\Miniconda3\lib\site-packages\conda\core\index.py", line 229, in get_reduced_index
        repodata_fn=repodata_fn)
      File "C:\Miniconda3\lib\site-packages\conda\core\subdir_data.py", line 105, in query_all
        result = tuple(concat(executor.map(subdir_query, channel_urls)))
      File "C:\Miniconda3\lib\concurrent\futures\_base.py", line 586, in result_iterator
        yield fs.pop().result()
      File "C:\Miniconda3\lib\concurrent\futures\_base.py", line 432, in result
        return self.__get_result()
      File "C:\Miniconda3\lib\concurrent\futures\_base.py", line 384, in __get_result
        raise self._exception
      File "C:\Miniconda3\lib\concurrent\futures\thread.py", line 57, in run
        result = self.fn(*self.args, **self.kwargs)
      File "C:\Miniconda3\lib\site-packages\conda\core\subdir_data.py", line 98, in <lambda>
        package_ref_or_match_spec))
      File "C:\Miniconda3\lib\site-packages\conda\core\subdir_data.py", line 110, in query
        self.load()
      File "C:\Miniconda3\lib\site-packages\conda\core\subdir_data.py", line 174, in load
        _internal_state = self._load()
      File "C:\Miniconda3\lib\site-packages\conda\core\subdir_data.py", line 248, in _load
        repodata_fn=self.repodata_fn)
      File "C:\Miniconda3\lib\site-packages\conda\core\subdir_data.py", line 581, in fetch_repodata_remote_request
        """) % maybe_unquote(repr(e))
      File "C:\Miniconda3\lib\site-packages\conda\common\url.py", line 368, in maybe_unquote
        return unquote_plus(remove_auth(url)) if url else url
      File "C:\Miniconda3\lib\site-packages\conda\common\url.py", line 372, in remove_auth
        url_parts = parse_url(url)._asdict()
      File "C:\Miniconda3\lib\site-packages\conda\_vendor\urllib3\util\url.py", line 196, in parse_url
        raise LocationParseError(url)
    conda._vendor.urllib3.exceptions.LocationParseError: Failed to parse: ConnectionError(MaxRetryError("HTTPSConnectionPool(host='repo.anaconda.com', port=443): Max retries exceeded with url: 
`$ C:\Miniconda3\Scripts\conda-script.py install --yes --prefix C:\_dev\python_ws\my_project\.nox\lint-3-7 pip`
  environment variables:
                 CIO_TEST=<not set>
        CONDA_DEFAULT_ENV=tools_py37
                CONDA_EXE=C:\Miniconda3\condabin\..\Scripts\conda.exe
               CONDA_EXES="C:\Miniconda3\condabin\..\Scripts\conda.exe"
             CONDA_PREFIX=C:\Miniconda3\envs\tools_py37
    CONDA_PROMPT_MODIFIER=(tools_py37)
               CONDA_ROOT=C:\Miniconda3
              CONDA_SHLVL=1
                 HOMEPATH=\Users\(...)
                     PATH=C:\Miniconda3;(...)
             PSMODULEPATH=%ProgramFiles%\WindowsPowerShell\Modules;C:\WINDOWS\system32\WindowsPo
                          werShell\v1.0\Modules
  PYTHONDONTWRITEBYTECODE=1
         PYTHONIOENCODING=UTF-8
               PYTHONPATH=(...)
         PYTHONUNBUFFERED=1
       REQUESTS_CA_BUNDLE=<not set>
            SSL_CERT_FILE=<not set>
    VBOX_MSI_INSTALL_PATH=C:\Program Files\Oracle\VirtualBox\
     active environment : tools_py37
    active env location : C:\Miniconda3\envs\tools_py37
            shell level : 1
       user config file : C:\Users\(...)\.condarc
 populated config files : C:\Users\(...)\.condarc
          conda version : 4.8.0
    conda-build version : not installed
         python version : 3.7.3.final.0
       virtual packages : __cuda=9.2
       base environment : C:\Miniconda3  (writable)
           channel URLs : https://repo.anaconda.com/pkgs/main/win-64
                          https://repo.anaconda.com/pkgs/main/noarch
                          https://repo.anaconda.com/pkgs/free/win-64
                          https://repo.anaconda.com/pkgs/free/noarch
                          https://repo.anaconda.com/pkgs/r/win-64
                          https://repo.anaconda.com/pkgs/r/noarch
                          https://repo.anaconda.com/pkgs/msys2/win-64
                          https://repo.anaconda.com/pkgs/msys2/noarch
          package cache : C:\Miniconda3\pkgs
                          C:\Users\(...)\.conda\pkgs
                          C:\Users\(...)\AppData\Local\conda\conda\pkgs
       envs directories : C:\Miniconda3\envs
                          C:\Users\(...)\.conda\envs
                          C:\Users\(...)\AppData\Local\conda\conda\envs
               platform : win-64
             user-agent : conda/4.8.0 requests/2.22.0 CPython/3.7.3 Windows/10 Windows/10.0.18362
          administrator : False
             netrc file : None
           offline mode : False
An unexpected error has occurred. Conda has prepared the above report.
Upload did not complete.
Traceback (most recent call last):
  File "C:\Program Files\JetBrains\PyCharm Community Edition 2019.2\plugins\python-ce\helpers\pydev\_pydevd_bundle\pydevd_exec2.py", line 3, in Exec
    exec(exp, global_vars, local_vars)
  File "<input>", line 1, in <module>
  File "C:\_dev\python_ws\_Libs_OpenSource\nox\nox\sessions.py", line 327, in conda_install
    **kwargs
  File "C:\_dev\python_ws\_Libs_OpenSource\nox\nox\sessions.py", line 277, 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 a conda shortcoming but I think that we could easily solve this in `conda_install` by detecting when network is unavailable and adding the `"--offline"` flag when it is the case. I'll propose a PR.



Pull request description:
Auto-offline for `conda_install`
Changelog (let me know if I should commit it in the changelog file):

 - Offline mode is now auto-detected by default by `conda_install`. This allows users to continue executing nox sessions on already installed environments. This behaviour can be disabled by setting `auto_offline=False`. Fixes #313


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..643f21c 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,9 +285,11 @@ 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:
+    def conda_install(
+        self, *args: str, auto_offline: bool = True, **kwargs: Any
+    ) -> None:
         """Install invokes `conda install`_ to install packages inside of the
         session's environment.
 
@@ -296,6 +304,10 @@ class Session:
             session.conda_install('--file', 'requirements.txt')
             session.conda_install('--file', 'requirements-dev.txt')
 
+        By default this method will detect when internet connection is not
+        available and will add the `--offline` flag automatically in that case.
+        To disable this behaviour, set `auto_offline=False`.
+
         To install the current package without clobbering conda-installed
         dependencies::
 
@@ -323,8 +335,22 @@ class Session:
         if "silent" not in kwargs:
             kwargs["silent"] = True
 
+        extraopts = ()  # type: Tuple[str, ...]
+        if auto_offline and venv.is_offline():
+            logger.warning(
+                "Automatically setting the `--offline` flag as conda repo seems unreachable."
+            )
+            extraopts = ("--offline",)
+
         self._run(
-            "conda", "install", "--yes", *prefix_args, *args, external="error", **kwargs
+            "conda",
+            "install",
+            "--yes",
+            *extraopts,
+            *prefix_args,
+            *args,
+            external="error",
+            **kwargs
         )
 
     def install(self, *args: str, **kwargs: Any) -> None:
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index fea0238..d3f48b6 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -16,8 +16,9 @@ import os
 import platform
 import re
 import shutil
+from socket import gethostbyname
 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 +49,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 +59,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")
@@ -148,7 +157,10 @@ class PassthroughEnv(ProcessEnv):
     hints about the actual env.
     """
 
-    pass
+    @staticmethod
+    def is_offline() -> bool:
+        """As of now this is only used in conda_install"""
+        return CondaEnv.is_offline()  # pragma: no cover
 
 
 class CondaEnv(ProcessEnv):
@@ -191,12 +203,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."""
@@ -231,6 +244,23 @@ class CondaEnv(ProcessEnv):
 
         return True
 
+    @staticmethod
+    def is_offline() -> bool:
+        """Return `True` if we are sure that the user is not able to connect to https://repo.anaconda.com.
+
+        Since an HTTP proxy might be correctly configured for `conda` using the `.condarc` `proxy_servers` section,
+        while not being correctly configured in the OS environment variables used by all other tools including python
+        `urllib` or `requests`, we are basically not able to do much more than testing the DNS resolution.
+
+        See details in this explanation: https://stackoverflow.com/a/62486343/7262247
+        """
+        try:
+            # DNS resolution to detect situation (1) or (2).
+            host = gethostbyname("repo.anaconda.com")
+            return host is None
+        except:  # pragma: no cover # noqa E722
+            return True
+
 
 class VirtualEnv(ProcessEnv):
     """Virtualenv management class.
@@ -339,12 +369,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..1da6f88 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
 
@@ -299,7 +310,11 @@ class TestSession:
         with pytest.raises(ValueError, match="conda environment"):
             session.conda_install()
 
-    def test_conda_install(self):
+    @pytest.mark.parametrize(
+        "auto_offline", [False, True], ids="auto_offline={}".format
+    )
+    @pytest.mark.parametrize("offline", [False, True], ids="offline={}".format)
+    def test_conda_install(self, auto_offline, offline):
         runner = nox.sessions.SessionRunner(
             name="test",
             signatures=["test"],
@@ -310,6 +325,7 @@ class TestSession:
         runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
         runner.venv.location = "/path/to/conda/env"
         runner.venv.env = {}
+        runner.venv.is_offline = lambda: offline
 
         class SessionNoSlots(nox.sessions.Session):
             pass
@@ -317,11 +333,13 @@ class TestSession:
         session = SessionNoSlots(runner=runner)
 
         with mock.patch.object(session, "_run", autospec=True) as run:
-            session.conda_install("requests", "urllib3")
+            args = ("--offline",) if auto_offline and offline else ()
+            session.conda_install("requests", "urllib3", auto_offline=auto_offline)
             run.assert_called_once_with(
                 "conda",
                 "install",
                 "--yes",
+                *args,
                 "--prefix",
                 "/path/to/conda/env",
                 "requests",
@@ -341,6 +359,7 @@ class TestSession:
         runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
         runner.venv.location = "/path/to/conda/env"
         runner.venv.env = {}
+        runner.venv.is_offline = lambda: False
 
         class SessionNoSlots(nox.sessions.Session):
             pass
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index 543d66f..0f85210 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,12 @@ 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_condaenv_(make_conda):
+    venv, dir_ = make_conda()
+    assert not venv.is_offline()
 
 
 def test_constructor_defaults(make_one):
@@ -209,13 +214,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 +257,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 +272,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