Repository: theacodes/nox
Pull request #100 (2018-08-22 21:01:01)

Adding support for 32-bit binaries on Windows.
An alternative implementation (and possibly a better one) would be to allow `arch` as an optional argument to `nox.session`. For example:

```python
@nox.session(python=["2.7", "3.7"], arch=["32", "64"])
def tests(session):
    session.install("pytest")
    session.install("-r", "requirements-dev.txt")
    session.install("-e", ".")
    session.run("py.test")
```

This would also give a "solution" to #60.

The real silly thing: I'm not sure anyone cares about 32-bit support. I used to test my OS X binaries for `bezier` in 32-bit, but then NumPy discontinued 32-bit support on OS X. Maybe that's a different beast since Apple controls the hardware?

dhermes: PS Sorry for sending a PR without a corresponding issue. Consider this PR a point for discussion as much as anything. I.e. let's close it if it seems like a bad idea.
theacodes: I feel like Nox should still support testing against these interpreters.

How well does Tox deal with this issue?
dhermes: I don't see any mention of it in `tox` docs, so it's unclear. (I am a bit [scared][1] to boot to Windows on my local machine, I could spin up AppVeyor to find out what `tox does?)

[1]: https://twitter.com/bossylobster/status/1031779928809721856
theacodes: If you don't mind doing the research.
dhermes: So AFAICT, the recommendation is to use `tox -e py`, which just defaults to the Python version where `tox` is installed. From the [packaging guide][2]:

> In order to run tests using `tox`, therefore, projects should probably
> use the default `py` environment in `tox`, which uses the Python
> interpreter that was used to run `tox`. This will ensure that when
> AppVeyor runs the tests, they will be run with the configured interpreter.

I ran a [simple][1] config and confirmed this works. From the [`tox` source][3] it seems clear there is no support for declaring 32-bit or 64-bit.

[1]: https://ci.appveyor.com/project/dhermes/bezier/build/1.0.1028.tox-wat
[2]: https://packaging.python.org/guides/supporting-windows-using-appveyor/#testing-with-tox
[3]: https://github.com/tox-dev/tox/blob/657b18e2d35ef58ac34be53442491c4f0a62efed/src/tox/interpreters.py#L165
theacodes: Okay, looking at this implementation I'm not sure how I feel about the environment variable functioning as an extra argument here.

Could we do something like:

```python
@nox.session(python=['3.6', '3.6-32'])
````
dhermes: I also don't feel great about using an environment variable. I used `PYTHON_ARCH` because those before us have done this for AppVeyor scripts.

There are only two architectures, but you'd still end up with a doubling of options (versus just adding a two-element list). For example:

```python
@nox.session(python=["2.7", "2.7-32", "3.5", "3.5-32", "3.6", "3.6-32", "3.7", "3.7-32"])
def foo(session):
    ...


@nox.session(python=["2.7", "3.5", "3.6", "3.7"], arch=["32", "64"])
def foo(session):
    ...
```
theacodes: Hrm, the section option doesn't let folks do things like "only test 32 bit python 3.7".

@lukesneeringer and I discussed a syntax like this previously:

```python
@nox.session(python=['3.6', nox.python('3.6', arch='32')])
def tests(s):
    pass
```

WDYT?
dhermes: My **initial** reaction was that I liked it. Then I thought about the implications a bit and liked it less, mostly because it makes the implementation more complex since it has to support "strings, AND ..." as the expected type. I think your suggestion of `3.6-32` is better and it has precedence: `py -3.6-32` is the preferred way with the Python Windows Launcher.

As for your 

> the ~~section~~ second option doesn't let folks do things like "only test 32 bit python 3.7".

I don't think it's actually a reason not to do it. `arch` is largely irrelevant on Linux and probably on OS X for most users. So the "extra" environments containing `arch` would only ever **need** to be run on Windows (or a Windows CI). (I realize this breaks the bare `nox`.)
theacodes: Hrm, so which direction are you leaning closer to now?
theacodes: Okay, so what would be the behavior for those things on Linux/Mac?
dhermes: I think on Linux it'd just be a `skip`? Or some text UI version of a `NotImplementedError`?

On Mac it's possible, but there are a few sharp corners, but I'm happy to write code / give advice.
theacodes: Cool, I'm happy with that as long as we have well defined cases for that.

On Thu, Aug 23, 2018 at 1:31 PM Danny Hermes <notifications@github.com>
wrote:

> I think on Linux it'd just be a skip? Or some text UI version of a
> NotImplementedError?
>
> On Mac it's possible, but there are a few sharp corners, but I'm happy to
> write code / give advice.
>
> —
> You are receiving this because you were assigned.
>
>
> Reply to this email directly, view it on GitHub
> <https://github.com/theacodes/nox/pull/100#issuecomment-415561368>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AAPUc6gtYi5GL2nP35nTCbqq_4ucd5zbks5uTxEtgaJpZM4WIaPK>
> .
>

theacodes: @dhermes is this ready? It seems to still be failing on Travis.

Patch:
diff --git a/docs/usage.rst b/docs/usage.rst
index e9d815f..a4054fd 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -125,6 +125,12 @@ Nox has provisional support for running on Windows. However, depending on your W
 * `Python issue 24493 <http://bugs.python.org/issue24493>`_
 * `Virtualenv issue 774 <https://github.com/pypa/virtualenv/issues/774>`_
 
+The Python binaries on Windows are found via the Python `Launcher`_ for
+Windows (``py``). For example, Python 3.5 can be found by determining which
+executable is invoked by ``py -3.5``. If a given test needs to use the 32-bit
+version of a given Python, then ``X.Y-32`` should be used as the version.
+
+.. _Launcher: https://docs.python.org/3/using/windows.html#python-launcher-for-windows
 
 Converting from tox
 -------------------
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 3dc1aba..de3df8e 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -52,7 +52,7 @@ class ProcessEnv:
 
 
 def locate_via_py(version):
-    """Find the Python executable using the Windows launcher.
+    """Find the Python executable using the Windows Launcher.
 
     This is based on :pep:397 which details that executing
     ``py.exe -{version}`` should execute python with the requested
@@ -61,7 +61,9 @@ def locate_via_py(version):
     specific Python interpreter.
 
     Args:
-        version (str): The desired Python version.
+        version (str): The desired Python version to pass to ``py.exe``. Of the form
+            ``X.Y`` or ``X.Y-32``. For example, a usage of the Windows Launcher might
+            be ``py -3.6-32``.
 
     Returns:
         Optional[str]: The full executable path for the Python ``version``,
@@ -77,7 +79,24 @@ def locate_via_py(version):
 
 
 class VirtualEnv(ProcessEnv):
-    """Virtualenv management class."""
+    """Virtualenv management class.
+
+    Args:
+        location (str): The location on the filesystem where the virtual environment
+            should be created.
+        interpreter (Optional[str]): The desired Python version. Of the form
+
+            * ``X.Y``, e.g. ``3.5``
+            * ``X.Y-32``. For example, a usage of the Windows Launcher might
+              be ``py -3.6-32``
+            * ``X.Y.Z``, e.g. ``3.4.9``
+            * ``pythonX.Y``, e.g. ``python2.7``
+            * A path in the filesystem to a Python executable
+
+            If not specified, this will use the currently running Python.
+        reuse_existing (Optional[bool]): Flag indicating if the virtual environment
+            should be reused if it already exists at ``location``.
+    """
 
     def __init__(self, location, interpreter=None, reuse_existing=False):
         self.location = os.path.abspath(location)
@@ -109,26 +128,41 @@ class VirtualEnv(ProcessEnv):
         # If this is just a X, X.Y, or X.Y.Z string, extract just the X / X.Y
         # part and add Python to the front of it.
         match = re.match(r"^(?P<xy_ver>\d(\.\d)?)(\.\d+)?$", self.interpreter)
+        xy_version = ""
         if match:
-            self.interpreter = "python{}".format(match.group("xy_ver"))
+            xy_version = match.group("xy_ver")
+            self.interpreter = "python{}".format(xy_version)
 
-        # Sanity check: We only need the rest of this behavior on Windows.
-        if platform.system() != "Windows":
+        # We may have gotten a fully-qualified interpreter path; accept this.
+        if py.path.local.sysfind(self.interpreter):
             return self.interpreter
 
-        # We may have gotten a fully-qualified interpreter path (for someone
-        # _only_ testing on Windows); accept this.
-        if py.path.local.sysfind(self.interpreter):
+        # Allow versions of the form ``X.Y-32`` for Windows.
+        if not xy_version:
+            match = re.match(r"^\d\.\d-32?$", self.interpreter)
+            if match:
+                xy_version = self.interpreter
+
+        # Sanity check: We only need the rest of this behavior on Windows.
+        if platform.system() != "Windows":
+            if xy_version.endswith("-32"):
+                raise RuntimeError(
+                    "Locating 32-bit Python ({!r}) is "
+                    "only supported on Windows.".format(self.interpreter)
+                )
             return self.interpreter
 
-        # If this is a standard Unix "pythonX.Y" name, it should be found
-        # in a standard location in Windows, and if not, the py.exe launcher
-        # should be able to find it from the information in the registry.
-        match = re.match(r"^python(?P<ver>\d\.?\d?)$", self.interpreter)
-        if match:
-            version = match.group("ver")
-            # Ask the Python launcher to find the interpreter.
-            path_from_launcher = locate_via_py(version)
+        # From here on out, we are on Windows. If ``self.interpreter`` has
+        # not produced a valid ``xy_version``, then we do one last check for
+        # a standard "pythonX" or "pythonX.Y".
+        if not xy_version:
+            match = re.match(r"^python(?P<ver>\d(\.\d)?)$", self.interpreter)
+            if match:
+                xy_version = match.group("ver")
+
+        # Ask the Python launcher to find the interpreter.
+        if xy_version:
+            path_from_launcher = locate_via_py(xy_version)
             if path_from_launcher:
                 return path_from_launcher
 


Test patch:
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index 4e8cce8..72ec772 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -168,66 +168,113 @@ def test__resolved_interpreter_none(make_one):
         ("3", "python3"),
         ("3.6", "python3.6"),
         ("3.6.2", "python3.6"),
-        ("2.", "2."),
-        ("2.7.", "2.7."),
         ("2.7.15", "python2.7"),
     ],
 )
-def test__resolved_interpreter_numerical_non_windows(make_one, input_, expected):
+@mock.patch.object(platform, "system", return_value="Linux")
+@mock.patch.object(py.path.local, "sysfind", return_value=None)
+def test__resolved_interpreter_numerical_non_windows(
+    sysfind, system, make_one, input_, expected
+):
     venv, _ = make_one(interpreter=input_)
-    with mock.patch.object(platform, "system") as system:
-        system.return_value = "Linux"
-        assert venv._resolved_interpreter == expected
-        system.assert_called_once_with()
+    assert system.call_count == 2
+
+    assert venv._resolved_interpreter == expected
+    sysfind.assert_called_once_with(expected)
+    assert system.call_count == 3
+    system.assert_has_calls([mock.call(), mock.call(), mock.call()])
 
 
-def test__resolved_interpreter_non_windows(make_one):
+@pytest.mark.parametrize("input_", ["2.", "2.7."])
+@mock.patch.object(platform, "system", return_value="Linux")
+@mock.patch.object(py.path.local, "sysfind", return_value=None)
+def test__resolved_interpreter_invalid_numerical_id(sysfind, system, make_one, input_):
+    venv, _ = make_one(interpreter=input_)
+    assert system.call_count == 2
+
+    assert venv._resolved_interpreter == input_
+    sysfind.assert_called_once_with(input_)
+    assert system.call_count == 3
+    system.assert_has_calls([mock.call(), mock.call(), mock.call()])
+
+
+@mock.patch.object(platform, "system", return_value="Linux")
+@mock.patch.object(py.path.local, "sysfind", return_value=None)
+def test__resolved_interpreter_32_bit_non_windows(sysfind, system, make_one):
+    venv, _ = make_one(interpreter="3.6-32")
+    assert system.call_count == 2
+
+    with pytest.raises(RuntimeError):
+        venv._resolved_interpreter
+    sysfind.assert_called_once_with("3.6-32")
+    assert system.call_count == 3
+    system.assert_has_calls([mock.call(), mock.call(), mock.call()])
+
+
+@mock.patch.object(platform, "system", return_value="Linux")
+@mock.patch.object(py.path.local, "sysfind", return_value=None)
+def test__resolved_interpreter_non_windows(sysfind, system, make_one):
     # Establish that the interpreter is simply passed through resolution
     # on non-Windows.
     venv, _ = make_one(interpreter="python3.6")
-    with mock.patch.object(platform, "system") as system:
-        system.return_value = "Linux"
-        assert venv._resolved_interpreter == "python3.6"
-        system.assert_called_once_with()
+    assert system.call_count == 2
+
+    assert venv._resolved_interpreter == "python3.6"
+    sysfind.assert_called_once_with("python3.6")
+    assert system.call_count == 3
+    system.assert_has_calls([mock.call(), mock.call(), mock.call()])
 
 
-def test__resolved_interpreter_windows_full_path(make_one):
-    # Establish that if we get a fully-qualified system path on Windows
-    # and the path exists, that we accept it.
+@mock.patch.object(platform, "system", return_value="Windows")
+@mock.patch.object(py.path.local, "sysfind")
+def test__resolved_interpreter_windows_full_path(sysfind, system, make_one):
+    # Establish that if we get a fully-qualified system path (on Windows
+    # or otherwise) and the path exists, that we accept it.
     venv, _ = make_one(interpreter=r"c:\Python36\python.exe")
-    with mock.patch.object(platform, "system") as system:
-        system.return_value = "Windows"
-        with mock.patch.object(py.path.local, "sysfind") as sysfind:
-            sysfind.return_value = py.path.local(venv.interpreter)
-            assert venv._resolved_interpreter == r"c:\Python36\python.exe"
-            system.assert_called_once_with()
-            sysfind.assert_called_once_with(r"c:\Python36\python.exe")
+    assert system.call_count == 2
 
+    sysfind.return_value = py.path.local(venv.interpreter)
+    assert venv._resolved_interpreter == r"c:\Python36\python.exe"
+    sysfind.assert_called_once_with(r"c:\Python36\python.exe")
+    assert system.call_count == 2
 
-@mock.patch.object(platform, "system")
+
+@pytest.mark.parametrize(
+    ["input_", "expected"],
+    [
+        ("3.7", r"c:\python37-x64\python.exe"),
+        ("python3.6", r"c:\python36-x64\python.exe"),
+        ("2.7-32", r"c:\python27\python.exe"),
+    ],
+)
+@mock.patch.object(platform, "system", return_value="Windows")
 @mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_windows_pyexe(sysfind, system, make_one):
+def test__resolved_interpreter_windows_pyexe(
+    sysfind, system, make_one, input_, expected
+):
     # Establish that if we get a standard pythonX.Y path, we look it
     # up via the py launcher on Windows.
-    venv, _ = make_one(interpreter="python3.6")
-
-    # Trick the system into thinking we are on Windows.
-    system.return_value = "Windows"
+    venv, _ = make_one(interpreter=input_)
+    assert system.call_count == 2
 
     # Trick the system into thinking that it cannot find python3.6
     # (it likely will on Unix). Also, when the system looks for the
     # py launcher, give it a dummy that returns our test value when
     # run.
-    attrs = {"sysexec.return_value": r"c:\python36\python.exe"}
+    attrs = {"sysexec.return_value": expected}
     mock_py = mock.Mock()
     mock_py.configure_mock(**attrs)
     sysfind.side_effect = lambda arg: mock_py if arg == "py" else False
 
     # Okay now run the test.
-    assert venv._resolved_interpreter == r"c:\python36\python.exe"
-    sysfind.assert_any_call("python3.6")
-    sysfind.assert_any_call("py")
-    system.assert_called_with()
+    assert venv._resolved_interpreter == expected
+    assert sysfind.call_count == 2
+    if input_ == "3.7":
+        sysfind.assert_has_calls([mock.call("python3.7"), mock.call("py")])
+    else:
+        sysfind.assert_has_calls([mock.call(input_), mock.call("py")])
+    assert system.call_count == 3
+    system.assert_has_calls([mock.call(), mock.call(), mock.call()])
 
 
 @mock.patch.object(platform, "system")