diff --git a/nox/virtualenv.py b/nox/virtualenv.py index bf930f7b..0296da08 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -62,6 +62,24 @@ def run(self, args, in_venv=True): path=self.bin if in_venv else None).run() +def locate_via_py(version): + """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 + version. We then make the python process print out its full + executable path which we use as the location for the version- + specific Python interpreter. + """ + script = "import sys; print(sys.executable)" + py_exe = py.path.local.sysfind('py') + if py_exe: + try: + return py_exe.sysexec('-' + version, '-c', script).strip() + except py.process.cmdexec.Error: + return None + + class VirtualEnv(ProcessEnv): """Virtualenv management class.""" @@ -102,17 +120,15 @@ def _resolved_interpreter(self): return self.interpreter # If this is a standard Unix "pythonX.Y" name, it should be found - # in a standard location in Windows. - match = re.match(r'^python(?P\d)\.(?P\d)$', self.interpreter) + # 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\d\.\d)$', self.interpreter) if match: - version = match.groupdict() - potential_paths = ( - r'c:\python{maj}{min}\python.exe'.format(**version), - r'c:\python{maj}{min}-x64\python.exe'.format(**version), - ) - for path in potential_paths: - if py.path.local(path).check(): - return str(path) + version = match.group('ver') + # Ask the Python launcher to find the interpreter. + path_from_launcher = locate_via_py(version) + if path_from_launcher: + return path_from_launcher # If we got this far, then we were unable to resolve the interpreter # to an actual executable; raise an exception. diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py index 09c0f9d9..78b6d847 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -226,28 +226,53 @@ def test__resolved_interpreter_windows_full_path(make_one): @mock.patch.object(platform, 'system') -@mock.patch.object(py._path.local.LocalPath, 'check') @mock.patch.object(py.path.local, 'sysfind') -def test__resolved_interpreter_windows_stloc(sysfind, check, system, make_one): - # Establish that if we get a standard pythonX.Y path, we map it to - # standard locations on Windows. +def test__resolved_interpreter_windows_pyexe(sysfind, system, make_one): + # 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' # Trick the system into thinking that it cannot find python3.6 - # (it likely will on Unix). - sysfind.return_value = False - - # Trick the system into thinking it _can_ find it in the Windows - # standard location. - check.return_value = True + # (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'} + 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' - check.assert_called_once_with() - sysfind.assert_called_once_with('python3.6') + sysfind.assert_any_call('python3.6') + sysfind.assert_any_call('py') + system.assert_called() + + +@mock.patch.object(platform, 'system') +@mock.patch.object(py.path.local, 'sysfind') +def test__resolved_interpreter_windows_pyexe_fails(sysfind, system, make_one): + # Establish that if the py launcher fails, we give the right error. + venv, _ = make_one(interpreter='python3.6') + + # Trick the system into thinking we are on Windows. + system.return_value = 'Windows' + + # 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 fails. + attrs = {'sysexec.side_effect': py.process.cmdexec.Error(1, 1, '', '', '')} + 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. + with pytest.raises(RuntimeError): + venv._resolved_interpreter + sysfind.assert_any_call('python3.6') + sysfind.assert_any_call('py') system.assert_called()