Repository: wntrblm/nox (python)

Issue:
`session.install` fails on MSYS2 virtualenv
### Current Behavior

Install `nox` using MSYS2 Python. (Use setup-msys2 action to create an environment on Github actions.)

`session.install(<anything>)` will fail, with errors such as the following:

 ```
nox > Error: python is not installed into the virtualenv, it is located at D:/a/_temp/msys64/mingw64/bin/python.EXE. Pass external=True into run() to explicitly allow this.
```

I tried to use `--no-error-on-external-run` flag to workaround this, but it looks like the implementation of `session.install` hard forces `external="error"` so this makes it a hard blocker.

### Expected Behavior

`nox` is able to install required dependencies into the virtualenv.

### Steps To Reproduce

See https://github.com/davidhewitt/msys2-nox-repro for a minimal repro to set up an environment in Github actions which reproduces this failure.

Failing run is e.g. https://github.com/davidhewitt/msys2-nox-repro/runs/7296705086?check_suite_focus=true

### Environment

```Markdown
- OS: Windows 10
- Python: 3.10 (MinGW / MSYS2)
- Nox: 2022.1.7
```


### Anything else?

_No response_

Pull request:
correct virtualenv bin dir under mingw python
I believe this fixes #630 

... at least, I was able to remove the workaround I had in https://github.com/PyO3/setuptools-rust/pull/501

Open to alternative ideas / suggestions how you'd like this tested.

Patch:
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index dcf2b0f..87df09e 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -19,11 +19,11 @@ import contextlib
 import functools
 import json
 import os
-import platform
 import re
 import shutil
 import subprocess
 import sys
+import sysconfig
 from pathlib import Path
 from socket import gethostbyname
 from typing import TYPE_CHECKING, Any, ClassVar
@@ -64,6 +64,11 @@ def __dir__() -> list[str]:
     return __all__
 
 
+# Use for test mocking and to make mypy happy
+_PLATFORM = sys.platform
+_IS_MINGW = sysconfig.get_platform().startswith("mingw")
+
+
 # Problematic environment variables that are stripped from all commands inside
 # of a virtualenv. See https://github.com/theacodes/nox/issues/44
 _BLACKLISTED_ENV_VARS = frozenset(
@@ -74,7 +79,6 @@ _BLACKLISTED_ENV_VARS = frozenset(
         "UV_SYSTEM_PYTHON",
     ]
 )
-_SYSTEM = platform.system()
 
 
 def find_uv() -> tuple[bool, str]:
@@ -359,7 +363,7 @@ class CondaEnv(ProcessEnv):
     def bin_paths(self) -> list[str]:
         """Returns the location of the conda env's bin folder."""
         # see https://github.com/conda/conda/blob/f60f0f1643af04ed9a51da3dd4fa242de81e32f4/conda/activate.py#L563-L572
-        if _SYSTEM == "Windows":
+        if _PLATFORM.startswith("win"):
             return [
                 self.location,
                 os.path.join(self.location, "Library", "mingw-w64", "bin"),
@@ -601,7 +605,7 @@ class VirtualEnv(ProcessEnv):
 
         # The rest of this is only applicable to Windows, so if we don't have
         # an interpreter by now, raise.
-        if _SYSTEM != "Windows":
+        if not _PLATFORM.startswith("win"):
             self._resolved = InterpreterNotFound(self.interpreter)
             raise self._resolved
 
@@ -630,7 +634,7 @@ class VirtualEnv(ProcessEnv):
     @property
     def bin_paths(self) -> list[str]:
         """Returns the location of the virtualenv's bin folder."""
-        if _SYSTEM == "Windows":
+        if _PLATFORM.startswith("win") and not _IS_MINGW:
             return [os.path.join(self.location, "Scripts")]
         return [os.path.join(self.location, "bin")]
 
diff --git a/noxfile.py b/noxfile.py
index 75dd7e3..7c23c90 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -18,14 +18,13 @@ from __future__ import annotations
 import contextlib
 import functools
 import os
-import platform
 import shutil
 import sqlite3
 import sys
 
 import nox
 
-ON_WINDOWS_CI = "CI" in os.environ and platform.system() == "Windows"
+ON_WINDOWS_CI = "CI" in os.environ and sys.platform.startswith("win32")
 
 nox.needs_version = ">=2024.4.15"
 nox.options.default_venv_backend = "uv|virtualenv"


Test patch:
diff --git a/tests/test_command.py b/tests/test_command.py
index dd1af5f..3811587 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -36,12 +36,12 @@ import nox.popen
 PYTHON = sys.executable
 
 skip_on_windows_primary_console_session = pytest.mark.skipif(
-    platform.system() == "Windows" and "SECONDARY_CONSOLE_SESSION" not in os.environ,
+    sys.platform.startswith("win") and "SECONDARY_CONSOLE_SESSION" not in os.environ,
     reason="On Windows, this test must run in a separate console session.",
 )
 
 only_on_windows = pytest.mark.skipif(
-    platform.system() != "Windows", reason="Only run this test on Windows."
+    not sys.platform.startswith("win"), reason="Only run this test on Windows."
 )
 
 
@@ -127,7 +127,7 @@ def test_run_verbosity_failed_command(
 
 
 @pytest.mark.skipif(
-    platform.system() == "Windows",
+    sys.platform.startswith("win"),
     reason="See https://github.com/python/cpython/issues/85815",
 )
 def test_run_non_str() -> None:
@@ -285,7 +285,7 @@ def enable_ctrl_c(*, enabled: bool) -> None:
 
 def interrupt_process(proc: subprocess.Popen[Any]) -> None:
     """Send SIGINT or CTRL_C_EVENT to the process."""
-    if platform.system() == "Windows":
+    if sys.platform.startswith("win"):
         # Disable Ctrl-C so we don't terminate ourselves.
         enable_ctrl_c(enabled=False)
 
@@ -301,7 +301,7 @@ def command_with_keyboard_interrupt(
     monkeypatch: pytest.MonkeyPatch, marker: Any
 ) -> None:
     """Monkeypatch Popen.communicate to raise KeyboardInterrupt."""
-    if platform.system() == "Windows":
+    if sys.platform.startswith("win"):
         # Enable Ctrl-C because the child inherits the setting from us.
         enable_ctrl_c(enabled=True)
 
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index a05885c..e364ea8 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
 
     from nox.virtualenv import CondaEnv, ProcessEnv, VirtualEnv
 
-IS_WINDOWS = nox.virtualenv._SYSTEM == "Windows"
+IS_WINDOWS = sys.platform.startswith("win")
 HAS_CONDA = shutil.which("conda") is not None
 HAS_UV = shutil.which("uv") is not None
 RAISE_ERROR = "RAISE_ERROR"
@@ -249,7 +249,7 @@ def test_conda_env_create_verbose(
     assert kwargs["log"]
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 def test_condaenv_bin_windows(make_conda: Callable[..., tuple[CondaEnv, Path]]) -> None:
     venv, dir_ = make_conda()
     assert [
@@ -406,7 +406,7 @@ def test_bin_paths(
     assert str(dir_.joinpath("Scripts" if IS_WINDOWS else "bin")) == venv.bin
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 def test_bin_windows(
     make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
 ) -> None:
@@ -417,6 +417,18 @@ def test_bin_windows(
     assert str(dir_.joinpath("Scripts")) == venv.bin
 
 
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv._IS_MINGW", new=True)
+def test_bin_windows_mingw(
+    make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
+    venv, dir_ = make_one()
+    assert venv.bin_paths
+    assert len(venv.bin_paths) == 1
+    assert venv.bin_paths[0] == venv.bin
+    assert str(dir_.joinpath("bin")) == venv.bin
+
+
 def test_create(
     monkeypatch: pytest.MonkeyPatch,
     make_one: Callable[..., tuple[VirtualEnv, Path]],
@@ -899,7 +911,7 @@ def test__resolved_interpreter_none(
         ("2.7.15", "python2.7"),
     ],
 )
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
 @mock.patch.object(shutil, "which", return_value=True)
 def test__resolved_interpreter_numerical_non_windows(
     which: mock.Mock,
@@ -914,7 +926,7 @@ def test__resolved_interpreter_numerical_non_windows(
 
 
 @pytest.mark.parametrize("input_", ["2.", "2.7."])
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
 @mock.patch.object(shutil, "which", return_value=False)
 def test__resolved_interpreter_invalid_numerical_id(
     which: mock.Mock,
@@ -929,7 +941,7 @@ def test__resolved_interpreter_invalid_numerical_id(
     which.assert_called_once_with(input_)
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
 @mock.patch.object(shutil, "which", return_value=False)
 def test__resolved_interpreter_32_bit_non_windows(
     which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
@@ -941,7 +953,7 @@ def test__resolved_interpreter_32_bit_non_windows(
     which.assert_called_once_with("3.6-32")
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
 @mock.patch.object(shutil, "which", return_value=True)
 def test__resolved_interpreter_non_windows(
     which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
@@ -954,7 +966,7 @@ def test__resolved_interpreter_non_windows(
     which.assert_called_once_with("python3.6")
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 @mock.patch.object(shutil, "which")
 def test__resolved_interpreter_windows_full_path(
     which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
@@ -976,7 +988,7 @@ def test__resolved_interpreter_windows_full_path(
         ("2.7-32", r"c:\python27\python.exe"),
     ],
 )
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 @mock.patch.object(subprocess, "run")
 @mock.patch.object(shutil, "which")
 def test__resolved_interpreter_windows_pyexe(
@@ -1011,7 +1023,7 @@ def test__resolved_interpreter_windows_pyexe(
     which.assert_has_calls([mock.call(input_), mock.call("py")])
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 @mock.patch.object(subprocess, "run")
 @mock.patch.object(shutil, "which")
 def test__resolved_interpreter_windows_pyexe_fails(
@@ -1020,8 +1032,8 @@ def test__resolved_interpreter_windows_pyexe_fails(
     # Establish that if the py launcher fails, we give the right error.
     venv, _ = make_one(interpreter="python3.6")
 
-    # Trick the nox.virtualenv._SYSTEM into thinking that it cannot find python3.6
-    # (it likely will on Unix). Also, when the nox.virtualenv._SYSTEM looks for the
+    # Trick the nox.virtualenv into thinking that it cannot find python3.6
+    # (it likely will on Unix). Also, when the nox.virtualenv looks for the
     # py launcher, give it a dummy that fails.
     def special_run(cmd: str, *args: str, **kwargs: object) -> TextProcessResult:  # noqa: ARG001
         return TextProcessResult("", 1)
@@ -1036,7 +1048,7 @@ def test__resolved_interpreter_windows_pyexe_fails(
     which.assert_has_calls([mock.call("python3.6"), mock.call("py")])
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 @mock.patch("nox.virtualenv.UV_PYTHON_SUPPORT", new=False)
 def test__resolved_interpreter_windows_path_and_version(
     make_one: Callable[..., tuple[VirtualEnv, Path]],
@@ -1065,7 +1077,7 @@ def test__resolved_interpreter_windows_path_and_version(
 @pytest.mark.parametrize("input_", ["2.7", "python3.7", "goofy"])
 @pytest.mark.parametrize("sysfind_result", [r"c:\python37-x64\python.exe", None])
 @pytest.mark.parametrize("sysexec_result", ["3.7.3\\n", RAISE_ERROR])
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 @mock.patch("nox.virtualenv.UV_PYTHON_SUPPORT", new=False)
 def test__resolved_interpreter_windows_path_and_version_fails(
     input_: str,
@@ -1089,7 +1101,7 @@ def test__resolved_interpreter_windows_path_and_version_fails(
         print(venv._resolved_interpreter)
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 @mock.patch.object(shutil, "which")
 def test__resolved_interpreter_not_found(
     which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
@@ -1106,7 +1118,7 @@ def test__resolved_interpreter_not_found(
         print(venv._resolved_interpreter)
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
 @mock.patch("nox.virtualenv.locate_via_py", new=lambda _: None)  # type: ignore[misc]  # noqa: PT008
 def test__resolved_interpreter_nonstandard(
     make_one: Callable[..., tuple[VirtualEnv, Path]],
@@ -1119,7 +1131,7 @@ def test__resolved_interpreter_nonstandard(
         print(venv._resolved_interpreter)
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
 @mock.patch.object(shutil, "which", return_value=True)
 def test__resolved_interpreter_cache_result(
     which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
@@ -1135,7 +1147,7 @@ def test__resolved_interpreter_cache_result(
     assert which.call_count == 1
 
 
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
 @mock.patch.object(shutil, "which", return_value=None)
 def test__resolved_interpreter_cache_failure(
     which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]