From 3bb2cf8fa0a183b629db8a80c02aecb9bc3bff39 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 22 Mar 2022 20:01:02 +0000 Subject: [PATCH 01/13] bpo-46566: Add new py.exe launcher implementation --- Doc/using/windows.rst | 50 +- Lib/test/test_launcher.py | 192 ++++ PC/launcher2.c | 2127 +++++++++++++++++++++++++++++++++++ PC/pylauncher.rc | 3 + PCbuild/pylauncher.vcxproj | 6 +- PCbuild/pywlauncher.vcxproj | 4 +- 6 files changed, 2375 insertions(+), 7 deletions(-) create mode 100644 Lib/test/test_launcher.py create mode 100644 PC/launcher2.c diff --git a/Doc/using/windows.rst b/Doc/using/windows.rst index 26c19ddbbce784..b2bbd589ec108e 100644 --- a/Doc/using/windows.rst +++ b/Doc/using/windows.rst @@ -817,6 +817,13 @@ minor version. I.e. ``/usr/bin/python2.7-32`` will request usage of the by the "-64" suffix. Furthermore it is possible to specify a major and architecture without minor (i.e. ``/usr/bin/python3-64``). +.. versionchanged:: 3.11 + + The "-64" suffix is deprecated, and now implies "any architecture that is + not provably i386/32-bit". To request a specific environment, use the new + ``-V:`` argument with the complete tag. + + The ``/usr/bin/env`` form of shebang line has one further special property. Before looking for installed Python interpreters, this form will search the executable :envvar:`PATH` for a Python executable. This corresponds to the @@ -937,13 +944,52 @@ For example: Diagnostics ----------- -If an environment variable ``PYLAUNCH_DEBUG`` is set (to any value), the +If an environment variable :envvar:`PYLAUNCH_DEBUG` is set (to any value), the launcher will print diagnostic information to stderr (i.e. to the console). While this information manages to be simultaneously verbose *and* terse, it should allow you to see what versions of Python were located, why a particular version was chosen and the exact command-line used to execute the -target Python. +target Python. It is primarily intended for testing and debugging. + +Install on demand +----------------- + +If an environment variable :envvar:`PYLAUNCH_ALLOW_INSTALL` is set (to any +value), and the requested Python version is not installed but is available on +the Microsoft Store, the launcher will attempt to install it. This may require +user interaction to complete, and you may need to run the command again. + + +Return codes +------------ +The following exit codes may be returned by the Python launcher. Unfortunately, +there is no way to distinguish these from the exit code of Python itself. + +The names of codes are as used in the sources, and are only for reference. There +is no way to access or resolve them apart from reading this page. Entries are +listed in alphabetical order of names + ++-------------------+-------+-----------------------------------------------+ +| Name | Value | Description | ++===================+=======+===============================================+ +| RC_BAD_VENV_CFG | 107 | A :file:`pyvenv.cfg` was found but is corrupt.| ++-------------------+-------+-----------------------------------------------+ +| RC_CREATE_PROCESS | 101 | Failed to launch Python. | ++-------------------+-------+-----------------------------------------------+ +| RC_INSTALLING | 111 | An install was started, but the command will | +| | | need to be re-run after it completes. | ++-------------------+-------+-----------------------------------------------+ +| RC_INTERNAL_ERROR | 109 | Unexpected error. Please report a bug. | ++-------------------+-------+-----------------------------------------------+ +| RC_NO_COMMANDLINE | 108 | Unable to obtain command line from the | +| | | operating system. | ++-------------------+-------+-----------------------------------------------+ +| RC_NO_PYTHON | 103 | Unable to locate the requested version. | ++-------------------+-------+-----------------------------------------------+ +| RC_NO_VENV_CFG | 106 | A :file:`pyvenv.cfg` was required but not | +| | | found. | ++-------------------+-------+-----------------------------------------------+ .. _finding_modules: diff --git a/Lib/test/test_launcher.py b/Lib/test/test_launcher.py new file mode 100644 index 00000000000000..aacf58a307601e --- /dev/null +++ b/Lib/test/test_launcher.py @@ -0,0 +1,192 @@ +import itertools +import os +import re +import subprocess +import sys +import sysconfig +import unittest +from pathlib import Path +from test import support + +if sys.platform != "win32": + raise unittest.SkipTest("test only applies to Windows") + +# Get winreg after the platform check +import winreg + + +PY_EXE = "py.exe" +if sys.executable.casefold().endswith("_d.exe".casefold()): + PY_EXE = "py_d.exe" + +# Registry data to create. On removal, everything beneath top-level names will +# be deleted. +TEST_DATA = { + "PythonTestSuite": { + "DisplayName": "Python Test Suite", + "SupportUrl": "https://www.python.org/", + "{0.major}.{0.minor}".format(sys.version_info): { + "DisplayName": "X.Y version", + "InstallPath": { + None: sys.prefix, + } + }, + "{0.major}.{0.minor}-32".format(sys.version_info): { + "DisplayName": "X.Y-32 version", + "InstallPath": { + None: sys.prefix, + } + }, + "{0.major}.{0.minor}-arm64".format(sys.version_info): { + "DisplayName": "X.Y-arm64 version", + "InstallPath": { + None: sys.prefix, + "ExecutablePath": sys.executable, + "ExecutableArguments": "-X fake_arg_for_test", + } + }, + } +} + + +def create_registry_data(root, data): + def _create_registry_data(root, key, value): + if isinstance(value, dict): + # For a dict, we recursively create keys + with winreg.CreateKeyEx(root, key) as hkey: + for k, v in value.items(): + _create_registry_data(hkey, k, v) + elif isinstance(value, str): + # For strings, we set values. 'key' may be None in this case + winreg.SetValueEx(root, key, None, winreg.REG_SZ, value) + else: + raise TypeError("don't know how to create data for '{}'".format(value)) + + for k, v in data.items(): + _create_registry_data(root, k, v) + + +def enum_keys(root): + for i in itertools.count(): + try: + yield winreg.EnumKey(root, i) + except OSError as ex: + if ex.winerror == 259: + break + raise + +def delete_registry_data(root, keys): + ACCESS = winreg.KEY_WRITE | winreg.KEY_ENUMERATE_SUB_KEYS + for key in list(keys): + with winreg.OpenKey(root, key, access=ACCESS) as hkey: + delete_registry_data(hkey, enum_keys(hkey)) + winreg.DeleteKey(root, key) + + +class TestLauncher(unittest.TestCase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.py_exe = None + + def setUp(self): + py_exe = None + if sysconfig.is_python_build(True): + py_exe = Path(sys.executable).parent / PY_EXE + else: + for p in os.getenv("PATH").split(";"): + if p: + py_exe = Path(p) / PY_EXE + if py_exe.is_file(): + break + if not py_exe: + raise unittest.SkipTest( + "cannot locate '{}' for test".format(PY_EXE) + ) + self.py_exe = py_exe + + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Python") as key: + create_registry_data(key, TEST_DATA) + + + def tearDown(self): + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, rf"Software\Python", access=winreg.KEY_WRITE | winreg.KEY_ENUMERATE_SUB_KEYS) as key: + delete_registry_data(key, TEST_DATA) + + + def run_py(self, args, env=None): + env = {**os.environ, **(env or {}), "PYLAUNCHER_DEBUG": "1", "PYLAUNCHER_DRYRUN": "1"} + with subprocess.Popen( + [self.py_exe, *args], + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as p: + p.stdin.close() + p.wait(10) + out = p.stdout.read().decode("ascii", "replace") + err = p.stderr.read().decode("ascii", "replace") + if p.returncode and support.verbose: + print("++ COMMAND ++") + print([self.py_exe, *args]) + print("++ STDOUT ++") + print(out) + print("++ STDERR ++") + print(err) + self.assertEqual(0, p.returncode) + data = { + s.partition(":")[0]: s.partition(":")[2].lstrip() + for s in err.splitlines() + if not s.startswith("#") and ":" in s + } + data["stdout"] = out + data["stderr"] = err + return data + + + def test_version(self): + data = self.run_py(["-0"]) + self.assertEqual(self.py_exe, Path(data["argv0"])) + self.assertEqual(sys.version.partition(" ")[0], data["version"]) + + def test_help_option(self): + data = self.run_py(["-h"]) + self.assertEqual("True", data["SearchInfo.help"]) + + def test_list_option(self): + for opt, v1, v2 in [ + ("-0", "True", "False"), + ("-0p", "False", "True"), + ("--list", "True", "False"), + ("--list-paths", "False", "True"), + ]: + with self.subTest(opt): + data = self.run_py([opt]) + self.assertEqual(v1, data["SearchInfo.list"]) + self.assertEqual(v2, data["SearchInfo.listPaths"]) + + def test_list(self): + data = self.run_py(["--list-paths"]) + found = {} + expect = {} + for line in data["stdout"].splitlines(): + m = re.match(r"\s*(.+?)\s+(.+)$", line) + if m: + found[m.group(1)] = m.group(2) + for company in TEST_DATA: + company_data = TEST_DATA[company] + tags = [t for t in company_data if isinstance(company_data[t], dict)] + for tag in tags: + arg = f"-V:{company}/{tag}" + install = company_data[tag]["InstallPath"] + try: + expect[arg] = install["ExecutablePath"] + try: + expect[arg] += " " + install["ExecutableArguments"] + except KeyError: + pass + except KeyError: + expect[arg] = str(Path(install[None]) / Path(sys.executable).name) + + actual = {k: v for k, v in found.items() if k in expect} + self.assertDictEqual(expect, actual) diff --git a/PC/launcher2.c b/PC/launcher2.c new file mode 100644 index 00000000000000..ac7ee1f5978968 --- /dev/null +++ b/PC/launcher2.c @@ -0,0 +1,2127 @@ +/* + * Rewritten Python launcher for Windows + * + * This new rewrite properly handles PEP 514 and allows any registered Python + * runtime to be launched. It also enables auto-install of versions when they + * are requested but no installation can be found. + */ + +#define __STDC_WANT_LIB_EXT1__ 1 + +#include +#include +#include +#include +#include +#include + +#define MS_WINDOWS +#include "patchlevel.h" + +#define MAXLEN PATHCCH_MAX_CCH +#define MSGSIZE 1024 + +#define RC_NO_STD_HANDLES 100 +#define RC_CREATE_PROCESS 101 +#define RC_BAD_VIRTUAL_PATH 102 +#define RC_NO_PYTHON 103 +#define RC_NO_MEMORY 104 +#define RC_NO_SCRIPT 105 +#define RC_NO_VENV_CFG 106 +#define RC_BAD_VENV_CFG 107 +#define RC_NO_COMMANDLINE 108 +#define RC_INTERNAL_ERROR 109 +#define RC_DUPLICATE_ITEM 110 +#define RC_INSTALLING 111 + +static FILE * log_fp = NULL; + +void +debug(wchar_t * format, ...) +{ + va_list va; + + if (log_fp != NULL) { + wchar_t buffer[MAXLEN]; + int r = 0; + va_start(va, format); + r = vswprintf_s(buffer, MAXLEN, format, va); + va_end(va); + + if (r <= 0) { + return; + } + fputws(buffer, log_fp); + while (r && isspace(buffer[r])) { + buffer[r--] = L'\0'; + } + if (buffer[0]) { + OutputDebugStringW(buffer); + } + } +} + + +void +formatWinerror(int rc, wchar_t * message, int size) +{ + FormatMessageW( + FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, rc, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + message, size, NULL); +} + + +void +winerror(int err, wchar_t * format, ... ) +{ + va_list va; + wchar_t message[MSGSIZE]; + wchar_t win_message[MSGSIZE]; + int len; + + if (err == 0) { + err = GetLastError(); + } + + va_start(va, format); + len = _vsnwprintf_s(message, MSGSIZE, _TRUNCATE, format, va); + va_end(va); + + formatWinerror(err, win_message, MSGSIZE); + if (len >= 0) { + _snwprintf_s(&message[len], MSGSIZE - len, _TRUNCATE, L": %s", + win_message); + } + +#if !defined(_WINDOWS) + fwprintf(stderr, L"%s\n", message); +#else + MessageBoxW(NULL, message, L"Python Launcher is sorry to say ...", + MB_OK); +#endif +} + + +void +error(wchar_t * format, ... ) +{ + va_list va; + wchar_t message[MSGSIZE]; + + va_start(va, format); + _vsnwprintf_s(message, MSGSIZE, _TRUNCATE, format, va); + va_end(va); + +#if !defined(_WINDOWS) + fwprintf(stderr, L"%s\n", message); +#else + MessageBoxW(NULL, message, L"Python Launcher is sorry to say ...", + MB_OK); +#endif +} + + +USHORT +_getNativeMachine() +{ + static USHORT _nativeMachine = IMAGE_FILE_MACHINE_UNKNOWN; + if (_nativeMachine == IMAGE_FILE_MACHINE_UNKNOWN) { + USHORT processMachine; + if (!IsWow64Process2(NULL, &processMachine, &_nativeMachine)) { + winerror(0, L"Checking process type"); + } + } + return _nativeMachine; +} + + +bool +isAMD64Host() +{ + return _getNativeMachine() == IMAGE_FILE_MACHINE_AMD64; +} + + +bool +isARM64Host() +{ + return _getNativeMachine() == IMAGE_FILE_MACHINE_ARM64; +} + + +bool +isEnvVarSet(const wchar_t *name) +{ + /* only looking for non-empty, which means at least one character + and the null terminator */ + return GetEnvironmentVariableW(name, NULL, 0) >= 2; +} + + +bool +join(wchar_t *buffer, size_t bufferLength, const wchar_t *fragment) +{ + if (SUCCEEDED(PathCchCombineEx(buffer, bufferLength, buffer, fragment, PATHCCH_ALLOW_LONG_PATHS))) { + return true; + } + return false; +} + + +int +_compare(const wchar_t *x, int xLen, const wchar_t *y, int yLen) +{ + // Empty strings sort first + if (xLen == 0) { + return yLen == 0 ? 0 : -1; + } else if (yLen == 0) { + return 1; + } + switch (CompareStringEx( + LOCALE_NAME_INVARIANT, NORM_IGNORECASE | SORT_DIGITSASNUMBERS, + x, xLen, y, yLen, + NULL, NULL, 0 + )) { + case CSTR_LESS_THAN: + return -1; + case CSTR_EQUAL: + return 0; + case CSTR_GREATER_THAN: + return 1; + default: + winerror(0, L"Error comparing '%.*s' and '%.*s' (compare)", xLen, x, yLen, y); + return -1; + } +} + + +int +_compareArgument(const wchar_t *x, int xLen, const wchar_t *y, int yLen) +{ + // Empty strings sort first + if (xLen == 0) { + return yLen == 0 ? 0 : -1; + } else if (yLen == 0) { + return 1; + } + switch (CompareStringEx( + LOCALE_NAME_INVARIANT, 0, + x, xLen, y, yLen, + NULL, NULL, 0 + )) { + case CSTR_LESS_THAN: + return -1; + case CSTR_EQUAL: + return 0; + case CSTR_GREATER_THAN: + return 1; + default: + winerror(0, L"Error comparing '%.*s' and '%.*s' (compareArgument)", xLen, x, yLen, y); + return -1; + } +} + +int +_comparePath(const wchar_t *x, int xLen, const wchar_t *y, int yLen) +{ + // Empty strings sort first + if (xLen == 0) { + return yLen == 0 ? 0 : -1; + } else if (yLen == 0) { + return 1; + } + switch (CompareStringOrdinal(x, xLen, y, yLen, TRUE)) { + case CSTR_LESS_THAN: + return -1; + case CSTR_EQUAL: + return 0; + case CSTR_GREATER_THAN: + return 1; + default: + winerror(0, L"Error comparing '%.*s' and '%.*s' (comparePath)", xLen, x, yLen, y); + return -1; + } +} + + +bool +_startsWith(const wchar_t *x, int xLen, const wchar_t *y, int yLen) +{ + yLen = yLen < 0 ? (int)wcsnlen_s(y, MAXLEN) : yLen; + xLen = xLen < 0 ? (int)wcsnlen_s(x, MAXLEN) : xLen; + return xLen >= yLen && 0 == _compare(x, yLen, y, yLen); +} + + +bool +_startsWithArgument(const wchar_t *x, int xLen, const wchar_t *y, int yLen) +{ + yLen = yLen < 0 ? (int)wcsnlen_s(y, MAXLEN) : yLen; + xLen = xLen < 0 ? (int)wcsnlen_s(x, MAXLEN) : xLen; + return xLen >= yLen && 0 == _compareArgument(x, yLen, y, yLen); +} + + +/******************************************************************************\ + *** HELP TEXT *** +\******************************************************************************/ + + +int +showHelpText(wchar_t ** argv) +{ + // The help text is stored in launcher-usage.txt, which is compiled into + // the launcher and loaded at runtime if needed. + // + // The file must be UTF-8. There are two substitutions: + // %ls - PY_VERSION (as wchar_t*) + // %ls - argv[0] (as wchar_t*) + HRSRC res = FindResourceExW(NULL, L"USAGE", MAKEINTRESOURCE(1), MAKELANGID(LANG_NEUTRAL, SUBLANG_NEUTRAL)); + HGLOBAL resData = res ? LoadResource(NULL, res) : NULL; + const char *usage = resData ? (const char*)LockResource(resData) : NULL; + if (usage == NULL) { + winerror(0, L"Unable to load usage text"); + return RC_INTERNAL_ERROR; + } + + DWORD cbData = SizeofResource(NULL, res); + DWORD cchUsage = MultiByteToWideChar(CP_UTF8, 0, usage, cbData, NULL, 0); + if (!cchUsage) { + winerror(0, L"Unable to preprocess usage text"); + return RC_INTERNAL_ERROR; + } + + cchUsage += 1; + wchar_t *wUsage = (wchar_t*)malloc(cchUsage * sizeof(wchar_t)); + cchUsage = MultiByteToWideChar(CP_UTF8, 0, usage, cbData, wUsage, cchUsage); + if (!cchUsage) { + winerror(0, L"Unable to preprocess usage text"); + free((void *)wUsage); + return RC_INTERNAL_ERROR; + } + // Ensure null termination + wUsage[cchUsage] = L'\0'; + + fwprintf(stdout, wUsage, (L"" PY_VERSION), argv[0]); + fflush(stdout); + + free((void *)wUsage); + + return 0; +} + + +/******************************************************************************\ + *** SEARCH INFO *** +\******************************************************************************/ + + +struct _SearchInfoBuffer { + struct _SearchInfoBuffer *next; + wchar_t buffer[0]; +}; + + +typedef struct { + // the original string, managed by the OS + const wchar_t *originalCmdLine; + // pointer into the cmdline to mark what we've consumed + const wchar_t *restOfCmdLine; + // if known/discovered, the full executable path of our runtime + const wchar_t *executablePath; + // pointer and length into cmdline for the file to check for a + // shebang line, if any. Length can be -1 if the string is null + // terminated. + const wchar_t *scriptFile; + int scriptFileLength; + // pointer and length into cmdline or a static string with the + // name of the target executable. Length can be -1 if the string + // is null terminated. + const wchar_t *executable; + int executableLength; + // pointer and length into cmdline or a static string with the + // company name for PEP 514 lookup. Length can be -1 if the string + // is null terminated. + const wchar_t *company; + int companyLength; + // pointer and length into cmdline or a static string with the + // tag for PEP 514 lookup. Length can be -1 if the string is + // null terminated. + const wchar_t *tag; + int tagLength; + // if true, treats 'tag' as a non-PEP 514 filter + bool oldStyleTag; + // if true, we had an old-style tag with '-64' suffix, and so do not + // want to match tags like '3.x-32' + bool exclude32Bit; + // if true, allow PEP 514 lookup to override 'executable' + bool allowExecutableOverride; + // if true, allow a nearby pyvenv.cfg to locate the executable + bool allowPyvenvCfg; + // if true, allow defaults (env/py.ini) to clarify/override tags + bool allowDefaults; + // if true, prefer windowed (console-less) executable + bool windowed; + // if true, only list detected runtimes without launching + bool list; + // if true, only list detected runtimes with paths without launching + bool listPaths; + // if true, display help message before contiuning + bool help; + // dynamically allocated buffers to free later + struct _SearchInfoBuffer *_buffer; +} SearchInfo; + + +wchar_t * +allocSearchInfoBuffer(SearchInfo *search, int wcharCount) +{ + struct _SearchInfoBuffer *buffer = (struct _SearchInfoBuffer*)malloc( + sizeof(struct _SearchInfoBuffer) + + wcharCount * sizeof(wchar_t) + ); + if (!buffer) { + return NULL; + } + buffer->next = search->_buffer; + search->_buffer = buffer; + return buffer->buffer; +} + + +void +freeSearchInfo(SearchInfo *search) +{ + struct _SearchInfoBuffer *b = search->_buffer; + search->_buffer = NULL; + while (b) { + struct _SearchInfoBuffer *nextB = b->next; + free((void *)b); + b = nextB; + } +} + + +void +_debugStringAndLength(const wchar_t *s, int len, const wchar_t *name) +{ + if (!s) { + debug(L"%s: (null)\n", name); + } else if (len == 0) { + debug(L"%s: (empty)\n", name); + } else if (len < 0) { + debug(L"%s: %s\n", name, s); + } else { + debug(L"%s: %.*ls\n", name, len, s); + } +} + + +void +dumpSearchInfo(SearchInfo *search) +{ + if (!log_fp) { + return; + } + +#define DEBUGNAME(s) L"SearchInfo." ## s +#define DEBUG(s) debug(DEBUGNAME(#s) L": %s\n", (search->s) ? (search->s) : L"(null)") +#define DEBUG_2(s, sl) _debugStringAndLength((search->s), (search->sl), DEBUGNAME(#s)) +#define DEBUG_BOOL(s) debug(DEBUGNAME(#s) L": %s\n", (search->s) ? L"True" : L"False") + DEBUG(originalCmdLine); + DEBUG(restOfCmdLine); + DEBUG(executablePath); + DEBUG_2(scriptFile, scriptFileLength); + DEBUG_2(executable, executableLength); + DEBUG_2(company, companyLength); + DEBUG_2(tag, tagLength); + DEBUG_BOOL(oldStyleTag); + DEBUG_BOOL(exclude32Bit); + DEBUG_BOOL(allowDefaults); + DEBUG_BOOL(allowExecutableOverride); + DEBUG_BOOL(windowed); + DEBUG_BOOL(list); + DEBUG_BOOL(listPaths); + DEBUG_BOOL(help); +#undef DEBUG_BOOL +#undef DEBUG_2 +#undef DEBUG +#undef DEBUGNAME +} + + +int +findArgumentLength(const wchar_t *buffer, int bufferLength) +{ + if (bufferLength < 0) { + bufferLength = (int)wcsnlen_s(buffer, MAXLEN); + } + if (bufferLength == 0) { + return 0; + } + const wchar_t *end; + int i; + + if (buffer[0] != L'"') { + end = wcschr(buffer, L' '); + if (!end) { + return bufferLength; + } + i = (int)(end - buffer); + return i < bufferLength ? i : bufferLength; + } + + i = 0; + while(i < bufferLength) { + end = wcschr(&buffer[i + 1], L'"'); + if (!end) { + return bufferLength + } + + i = (int)(end - buffer); + if (i >= bufferLength) { + return bufferLength; + } + + int j = i; + while (j > 1 && buffer[--j] == L'\\') { + if (j > 0 && buffer[--j] == L'\\') { + // Even number, so back up and keep counting + } else { + // Odd number, so it's escaped and we want to keep searching + continue; + } + } + + // Non-escaped quote with space after it - end of the argument! + if (j + 1 < bufferLength && isspace(buffer[j + 1])) { + return i; + } + } + + return bufferLength; +} + + +const wchar_t * +findArgumentEnd(const wchar_t *buffer, int bufferLength) +{ + return &buffer[findArgumentLength(buffer, bufferLength)] +} + + +/******************************************************************************\ + *** COMMAND-LINE PARSING *** +\******************************************************************************/ + + +int +parseCommandLine(SearchInfo *search) +{ + if (!search || !search->originalCmdLine) { + return RC_NO_COMMANDLINE; + } + + const wchar_t *tail = findArgumentEnd(search->originalCmdLine); + const wchar_t *end = tail; + search->restOfCmdLine = tail; + while (--tail != search->originalCmdLine) { + if (*tail == L'.' && end == search->restOfCmdLine) { + end = tail; + } else if (*tail == L'\\' || *tail == L'/') { + ++tail; + break; + } + } + // Without special cases, we can now fill in the search struct + int tailLen = (int)(end ? (end - tail) : wcsnlen_s(tail, MAXLEN)); + search->executableLength = -1; + + // Our special cases are as follows +#define MATCHES(s) (0 == _comparePath(tail, tailLen, (s), -1)) +#define STARTSWITH(s) _startsWith(tail, tailLen, (s), -1) + if (MATCHES(L"py")) { + search->executable = L"python.exe"; + search->allowExecutableOverride = true; + search->allowDefaults = true; + } else if (MATCHES(L"pyw")) { + search->executable = L"pythonw.exe"; + search->allowExecutableOverride = true; + search->allowDefaults = true; + search->windowed = true; + } else if (MATCHES(L"py_d")) { + search->executable = L"python_d.exe"; + search->allowExecutableOverride = true; + search->allowDefaults = true; + } else if (MATCHES(L"pyw_d")) { + search->executable = L"pythonw_d.exe"; + search->allowExecutableOverride = true; + search->allowDefaults = true; + search->windowed = true; + } else if (STARTSWITH(L"python3")) { + search->executable = L"python.exe"; + search->tag = &tail[6]; + search->tagLength = tailLen - 6; + search->allowExecutableOverride = true; + search->oldStyleTag = true; + search->allowPyvenvCfg = true; + } else if (STARTSWITH(L"pythonw3")) { + search->executable = L"pythonw.exe"; + search->tag = &tail[7]; + search->tagLength = tailLen - 7; + search->allowExecutableOverride = true; + search->oldStyleTag = true; + search->allowPyvenvCfg = true; + search->windowed = true; + } else { + search->executable = tail; + search->executableLength = tailLen; + search->allowPyvenvCfg = true; + } +#undef STARTSWITH +#undef MATCHES + + // First argument might be one of our options. If so, consume it, + // update flags and then set restOfCmdLine. + const wchar_t *arg = search->restOfCmdLine; + while(*arg && isspace(*arg)) { ++arg; } +#define MATCHES(s) (0 == _compareArgument(arg, argLen, (s), -1)) +#define STARTSWITH(s) _startsWithArgument(arg, argLen, (s), -1) + if (*arg && *arg == L'-' && *++arg) { + tail = arg; + while (*tail && !isspace(*tail)) { ++tail; } + int argLen = (int)(tail - arg); + if (argLen > 0) { + if (STARTSWITH(L"2") || STARTSWITH(L"3")) { + // All arguments starting with 2 or 3 are assumed to be version tags + search->tag = arg; + search->tagLength = argLen; + search->oldStyleTag = true; + search->restOfCmdLine = tail; + // If the tag ends with -64, we want to exclude 32-bit runtimes + // (If the tag ends with -32, it will filter fine, so no more is needed) + if (argLen > 3 && 0 == _compareArgument(&arg[argLen - 3], 3, L"-64", 3)) { + search->tagLength -= 3; + search->exclude32Bit = true; + } + } else if (STARTSWITH(L"V:") || STARTSWITH(L"-version:")) { + // Arguments starting with 'V:' specify company and/or tag + const wchar_t *argStart = wcschr(arg, L':') + 1; + const wchar_t *tagStart = wcschr(argStart, L'/') ; + if (tagStart) { + search->company = argStart; + search->companyLength = (int)(tagStart - argStart); + search->tag = tagStart + 1; + } else { + search->tag = argStart; + } + search->tagLength = (int)(tail - search->tag); + search->restOfCmdLine = tail; + } else if (MATCHES(L"0") || MATCHES(L"-list")) { + search->list = true; + search->restOfCmdLine = tail; + } else if (MATCHES(L"0p") || MATCHES(L"-list-paths")) { + search->listPaths = true; + search->restOfCmdLine = tail; + } else if (MATCHES(L"h") || MATCHES(L"-help")) { + search->help = true; + // Do not update restOfCmdLine so that we trigger the help + // message from whichever interpreter we select + } + } + } +#undef STARTSWITH +#undef MATCHES + + // Might have a script filename. If it looks like a filename, add + // it to the SearchInfo struct for later reference. + arg = search->restOfCmdLine; + while(*arg && isspace(*arg)) { ++arg; } + if (*arg && *arg != L'-') { + search->scriptFile = arg; + if (*arg == L'"') { + ++search->scriptFile; + while (*++arg && *arg != L'"') { } + } else { + while (*arg && !isspace(*arg)) { ++arg; } + } + search->scriptFileLength = (int)(arg - search->scriptFile); + } + + return 0; +} + + +int +_decodeShebang(SearchInfo *search, const char *buffer, int bufferLength, bool onlyUtf8, wchar_t **decoded, int *decodedLength) +{ + DWORD cp = CP_UTF8; + int wideLen = MultiByteToWideChar(cp, MB_ERR_INVALID_CHARS, buffer, bufferLength, NULL, 0); + if (!wideLen) { + cp = CP_ACP; + wideLen = MultiByteToWideChar(cp, MB_ERR_INVALID_CHARS, buffer, bufferLength, NULL, 0); + if (!wideLen) { + debug(L"# Failed to decode shebang line (0x%08X)\n", GetLastError()); + return RC_BAD_VIRTUAL_PATH; + } + } + wchar_t *b = allocSearchInfoBuffer(search, wideLen + 1); + if (!b) { + return RC_NO_MEMORY; + } + wideLen = MultiByteToWideChar(cp, 0, buffer, bufferLength, b, wideLen + 1); + if (!wideLen) { + debug(L"# Failed to decode shebang line (0x%08X)\n", GetLastError()); + return RC_BAD_VIRTUAL_PATH; + } + b[wideLen] = L'\0'; + *decoded = b; + *decodedLength = wideLen; + return 0; +} + + +bool +_shebangStartsWith(const wchar_t *buffer, int bufferLength, const wchar_t *prefix, const wchar_t **rest) +{ + int prefixLength = (int)wcsnlen_s(prefix, MAXLEN); + if (bufferLength < prefixLength) { + return false; + } + if (rest) { + *rest = &buffer[prefixLength]; + } + return _startsWithArgument(buffer, bufferLength, prefix, prefixLength); +} + + +int +_readIni(const wchar_t *section, const wchar_t *settingName, wchar_t *buffer, int bufferLength) +{ + wchar_t iniPath[MAXLEN]; + int n; + if (SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, iniPath)) && + join(iniPath, MAXLEN, L"py.ini")) { + debug(L"# Reading from %s for %s/%s\n", iniPath, section, settingName); + n = GetPrivateProfileStringW(section, settingName, NULL, buffer, bufferLength, iniPath); + if (n) { + debug(L"# Found %s in %s\n", settingName, iniPath); + return true; + } else if (GetLastError() != ERROR_FILE_NOT_FOUND) { + winerror(0, L"Failed to read from %s\n", iniPath); + } + } + if (GetModuleFileNameW(NULL, iniPath, MAXLEN) && + SUCCEEDED(PathCchRemoveFileSpec(iniPath, MAXLEN)) && + join(iniPath, MAXLEN, L"py.ini")) { + debug(L"# Reading from %s for %s/%s\n", iniPath, section, settingName); + n = GetPrivateProfileStringW(section, settingName, NULL, buffer, MAXLEN, iniPath); + if (n) { + debug(L"# Found %s in %s\n", settingName, iniPath); + return n; + } else if (GetLastError() != ERROR_FILE_NOT_FOUND) { + winerror(0, L"Failed to read from %s\n", iniPath); + } + } + return 0; +} + + +bool +_findCommand(SearchInfo *search, const wchar_t *command, int commandLength) +{ + wchar_t commandBuffer[MAXLEN]; + wchar_t buffer[MAXLEN]; + wcsncpy_s(commandBuffer, MAXLEN, command, commandLength); + int n = _readIni(L"commands", commandBuffer, buffer, MAXLEN); + if (!n) { + return false; + } + wchar_t *path = allocSearchInfoBuffer(search, n + 1); + if (!path) { + return false; + } + wcscpy_s(path, n + 1, buffer); + search->executablePath = path; + return true; +} + + +int +checkShebang(SearchInfo *search) +{ + // Do not check shebang if a tag was provided or if no script file + // was found on the command line. + if (search->tag || !search->scriptFile) { + return 0; + } + + if (search->scriptFileLength < 0) { + search->scriptFileLength = (int)wcsnlen_s(search->scriptFile, MAXLEN); + } + + wchar_t *scriptFile = (wchar_t*)malloc(sizeof(wchar_t) * (search->scriptFileLength + 1)); + if (!scriptFile) { + return RC_NO_MEMORY; + } + + wcsncpy_s(scriptFile, search->scriptFileLength + 1, + search->scriptFile, search->scriptFileLength); + + HANDLE hFile = CreateFileW(scriptFile, GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, 0, NULL); + + if (hFile == INVALID_HANDLE_VALUE) { + debug(L"# Failed to open %s for shebang parsing (0x%08X)\n", + scriptFile, GetLastError()); + free(scriptFile); + return 0; + } + + DWORD bytesRead = 0; + char buffer[4096]; + if (!ReadFile(hFile, buffer, sizeof(buffer), &bytesRead, NULL)) { + debug(L"# Failed to read %s for shebang parsing (0x%08X)\n", + scriptFile, GetLastError()); + free(scriptFile); + return 0; + } + + CloseHandle(hFile); + debug(L"# Read %d bytes from %s to find shebang line\n", bytesRead, scriptFile); + free(scriptFile); + + + char *b = buffer; + bool onlyUtf8 = false; + if (bytesRead > 3 && *b == 0xEF) { + if (*++b == 0xBB && *++b == 0xBF) { + // Allow a UTF-8 BOM + ++b; + bytesRead -= 3; + onlyUtf8 = true; + } else { + debug(L"# Invalid BOM in shebang line"); + return 0; + } + } + if (bytesRead <= 2 || b[0] != '#' || b[1] != '!') { + // No shebang (#!) at start of line + debug(L"# No valid shebang line"); + return 0; + } + ++b; + --bytesRead; + while (--bytesRead > 0 && isspace(*++b)) { } + char *start = b; + while (--bytesRead > 0 && *++b != '\r' && *b != '\n') { } + wchar_t *shebang; + int shebangLength; + int exitCode = _decodeShebang(search, start, (int)(b - start), onlyUtf8, &shebang, &shebangLength); + if (exitCode) { + return exitCode; + } + debug(L"Shebang: %s\n", shebang); + + // Handle some known, case-sensitive shebang templates + const wchar_t *command; + int commandLength; + static const wchar_t *shebangTemplates[] = { + L"/usr/bin/env ", + L"/usr/bin/", + L"/usr/local/bin/", + L"", + NULL + }; + for (const wchar_t **tmpl = shebangTemplates; *tmpl; ++tmpl) { + if (_shebangStartsWith(shebang, shebangLength, *tmpl, &command)) { + commandLength = 0; + while (command[commandLength] && !isspace(command[commandLength])) { + commandLength += 1; + } + if (!commandLength) { + } else if (_findCommand(search, command, commandLength)) { + debug(L"# Treating shebang command '%.*s' as %s\n", + commandLength, command, search->executablePath); + } else if (_shebangStartsWith(command, commandLength, L"python", NULL)) { + search->tag = &command[6]; + search->tagLength = commandLength - 6; + search->oldStyleTag = true; + debug(L"# Treating shebang command '%.*s' as 'py -V:%.*s'\n", + commandLength, command, search->tagLength, search->tag); + } else { + debug(L"# Found shebang command but could not execute it: %.*s\n", + commandLength, command); + } + break; + } + } + + return 0; +} + + +int +checkDefaults(SearchInfo *search) +{ + if (!search->allowDefaults) { + return 0; + } + + wchar_t buffer[MAXLEN]; + int n; + + // If no tag was provided at all, look for an active virtual environment + if (!search->tag || !search->tagLength) { + n = GetEnvironmentVariableW(L"VIRTUAL_ENV", buffer, MAXLEN); + if (n && join(buffer, MAXLEN, L"Scripts") && join(buffer, MAXLEN, search->executable)) { + if (INVALID_FILE_ATTRIBUTES == GetFileAttributesW(buffer)) { + n = (int)wcsnlen_s(buffer, MAXLEN) + 1; + wchar_t *path = allocSearchInfoBuffer(search, n); + if (!path) { + return RC_NO_MEMORY; + } + search->executablePath = path; + wcscpy_s(path, n, buffer); + return 0; + } else { + debug(L"Python executable %s missing from virtual env\n", buffer); + } + } + } + + // Only resolve old-style (or absent) tags to defaults + if (search->tag && search->tagLength && !search->oldStyleTag) { + return 0; + } + + // If tag is only a major version number, expand it from the environment + // or an ini file + const wchar_t *settingName = NULL; + if (!search->tag || !search->tagLength) { + settingName = L"py_python"; + } else if (0 == wcsncmp(search->tag, L"3", search->tagLength)) { + settingName = L"py_python3"; + } else if (0 == wcsncmp(search->tag, L"2", search->tagLength)) { + settingName = L"py_python2"; + } else { + debug(L"# Cannot select defaults for tag '%.*s'\n", search->tagLength, search->tag); + return 0; + } + + // First, try to read an environment variable + n = GetEnvironmentVariableW(settingName, buffer, MAXLEN); + + // If none found, check in our two .ini files instead + if (!n) { + n = _readIni(L"defaults", settingName, buffer, MAXLEN); + } + + if (n) { + wchar_t *tag = allocSearchInfoBuffer(search, n + 1); + if (!tag) { + return RC_NO_MEMORY; + } + wcscpy_s(tag, n + 1, buffer); + search->tag = tag; + search->tagLength = n; + } + + return 0; +} + +/******************************************************************************\ + *** ENVIRONMENT SEARCH *** +\******************************************************************************/ + +typedef struct EnvironmentInfo { + /* We use a binary tree and sort on insert */ + struct EnvironmentInfo *prev; + struct EnvironmentInfo *next; + /* parent is only used when constructing */ + struct EnvironmentInfo *parent; + const wchar_t *company; + const wchar_t *tag; + int internalSortKey; + const wchar_t *installDir; + const wchar_t *executablePath; + const wchar_t *executableArgs; + const wchar_t *architecture; +} EnvironmentInfo; + + +int +copyWstr(const wchar_t **dest, const wchar_t *src) +{ + if (!dest) { + return RC_NO_MEMORY; + } + if (!src) { + *dest = NULL; + return 0; + } + size_t n = wcsnlen_s(src, MAXLEN - 1) + 1; + wchar_t *buffer = (wchar_t*)malloc(n * sizeof(wchar_t)); + if (!buffer) { + return RC_NO_MEMORY; + } + wcsncpy_s(buffer, n, src, n - 1); + *dest = (const wchar_t*)buffer; + return 0; +} + + +EnvironmentInfo * +newEnvironmentInfo(const wchar_t *company, const wchar_t *tag) +{ + EnvironmentInfo *env = (EnvironmentInfo *)malloc(sizeof(EnvironmentInfo)); + if (!env) { + return NULL; + } + memset(env, 0, sizeof(EnvironmentInfo)); + int exitCode = copyWstr(&env->company, company); + if (exitCode) { + free((void *)env); + return NULL; + } + exitCode = copyWstr(&env->tag, tag); + if (exitCode) { + free((void *)env->company); + free((void *)env); + return NULL; + } + return env; +} + + +void +freeEnvironmentInfo(EnvironmentInfo *env) +{ + if (env) { + free((void *)env->company); + free((void *)env->tag); + free((void *)env->installDir); + free((void *)env->executablePath); + free((void *)env->executableArgs); + freeEnvironmentInfo(env->prev); + env->prev = NULL; + freeEnvironmentInfo(env->next); + env->next = NULL; + free((void *)env); + } +} + + +/* Specific string comparisons for sorting the tree */ + +int +_compareCompany(const wchar_t *x, const wchar_t *y) +{ + bool coreX = 0 == _compare(x, -1, L"PythonCore", -1); + bool coreY = 0 == _compare(y, -1, L"PythonCore", -1); + if (coreX) { + return coreY ? 0 : -1; + } else if (coreY) { + return 1; + } + return _compare(x, -1, y, -1); +} + + +int +_compareTag(const wchar_t *x, const wchar_t *y) +{ + // Compare up to the first dash. If not equal, that's our sort order + const wchar_t *xDash = wcschr(x, L'-'); + const wchar_t *yDash = wcschr(y, L'-'); + int xToDash = xDash ? (int)(xDash - x) : -1; + int yToDash = yDash ? (int)(yDash - y) : -1; + int r = _compare(x, xToDash, y, yToDash); + if (r) { + return r; + } + // If we're equal up to the first dash, we want to sort one with + // no dash *after* one with a dash. Otherwise, a regular compare + if (xDash && yDash) { + return _compare(xDash, -1, yDash, -1); + } else if (xDash) { + return -1; + } else if (yDash) { + return 1; + } + return 0; +} + + +int +addEnvironmentInfo(EnvironmentInfo **root, EnvironmentInfo *node) +{ + EnvironmentInfo *r = *root; + if (!r) { + *root = node; + node->parent = NULL; + return 0; + } + // Sort by company name + switch (_compareCompany(node->company, r->company)) { + case -1: + return addEnvironmentInfo(&r->prev, node); + case 1: + return addEnvironmentInfo(&r->next, node); + case 0: + break; + } + // Then by tag (descending) + switch (_compareTag(node->tag, r->tag)) { + case -1: + return addEnvironmentInfo(&r->next, node); + case 1: + return addEnvironmentInfo(&r->prev, node); + case 0: + break; + } + // Then keep the one with the lowest internal sort key + if (r->internalSortKey < node->internalSortKey) { + // Replace the current node + node->parent = r->parent; + if (node->parent) { + if (node->parent->prev == r) { + node->parent->prev = node; + } else if (node->parent->next == r) { + node->parent->next = node; + } else { + debug(L"# Inconsistent parent value in tree\n"); + freeEnvironmentInfo(node); + return RC_INTERNAL_ERROR; + } + } + node->next = r->next; + node->prev = r->prev; + } else { + debug(L"# not adding %s/%s/%i to tree\n", node->company, node->tag, node->internalSortKey); + freeEnvironmentInfo(node); + return RC_DUPLICATE_ITEM; + } + return 0; +} + + +/******************************************************************************\ + *** REGISTRY SEARCH *** +\******************************************************************************/ + + +int +_registryReadString(const wchar_t **dest, HKEY root, const wchar_t *subkey, const wchar_t *value) +{ + // Note that this is bytes (hence 'cb'), not characters ('cch') + DWORD cbData = 0; + DWORD flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ; + + if (ERROR_SUCCESS != RegGetValueW(root, subkey, value, flags, NULL, NULL, &cbData)) { + return 0; + } + + wchar_t *buffer = (wchar_t*)malloc(cbData); + if (!buffer) { + return RC_NO_MEMORY; + } + + if (ERROR_SUCCESS == RegGetValueW(root, subkey, value, flags, NULL, buffer, &cbData)) { + *dest = buffer; + } else { + free((void *)buffer); + } + return 0; +} + + +int +_combineWithInstallDir(const wchar_t **dest, const wchar_t *installDir, const wchar_t *fragment, int fragmentLength) +{ + wchar_t buffer[MAXLEN]; + wchar_t fragmentBuffer[MAXLEN]; + if (wcsncpy_s(fragmentBuffer, MAXLEN, fragment, fragmentLength)) { + return RC_NO_MEMORY; + } + + if (FAILED(PathCchCombineEx(buffer, MAXLEN, installDir, fragmentBuffer, PATHCCH_ALLOW_LONG_PATHS))) { + return RC_NO_MEMORY; + } + + return copyWstr(dest, buffer); +} + + +int +_registryReadEnvironment(const SearchInfo *search, HKEY root, EnvironmentInfo *env, const wchar_t *fallbackArch) +{ + int exitCode = _registryReadString(&env->installDir, root, L"InstallPath", NULL); + if (exitCode) { + return exitCode; + } + if (!env->installDir) { + return RC_NO_PYTHON; + } + + // If pythonw.exe requested, check specific value + if (search->windowed) { + exitCode = _registryReadString(&env->executablePath, root, L"InstallPath", L"WindowedExecutablePath"); + if (!exitCode && env->executablePath) { + exitCode = _registryReadString(&env->executableArgs, root, L"InstallPath", L"WindowedExecutableArguments"); + } + } + if (exitCode) { + return exitCode; + } + + // Missing windowed path or non-windowed request means we use ExecutablePath + if (!env->executablePath) { + exitCode = _registryReadString(&env->executablePath, root, L"InstallPath", L"ExecutablePath"); + if (!exitCode && env->executablePath) { + exitCode = _registryReadString(&env->executableArgs, root, L"InstallPath", L"ExecutableArguments"); + } + } + if (exitCode) { + return exitCode; + } + + exitCode = _registryReadString(&env->architecture, root, NULL, L"SysArchitecture"); + if (exitCode) { + return exitCode; + } + + // Only PythonCore entries will infer executablePath from installDir and architecture from the binary + if (0 == _compare(env->company, -1, L"PythonCore", -1)) { + if (!env->executablePath) { + exitCode = _combineWithInstallDir( + &env->executablePath, + env->installDir, + search->executable, + search->executableLength + ); + if (exitCode) { + return exitCode; + } + } + if (!env->architecture && env->executablePath && fallbackArch) { + copyWstr(&env->architecture, fallbackArch); + } + } + + if (!env->executablePath) { + debug(L"# %s/%s has no executable path\n", env->company, env->tag); + return RC_NO_PYTHON; + } + + return 0; +} + +int +_registrySearchTags(const SearchInfo *search, EnvironmentInfo **result, HKEY root, int sortKey, const wchar_t *company, const wchar_t *fallbackArch) +{ + wchar_t buffer[256]; + int err = 0; + int exitCode = 0; + for (int i = 0; exitCode == 0; ++i) { + DWORD cchBuffer = sizeof(buffer) / sizeof(buffer[0]); + err = RegEnumKeyExW(root, i, buffer, &cchBuffer, NULL, NULL, NULL, NULL); + if (err) { + if (err != ERROR_NO_MORE_ITEMS) { + winerror(0, L"Failed to read installs (tags) from the registry"); + } + break; + } + HKEY subkey; + if (ERROR_SUCCESS == RegOpenKeyExW(root, buffer, 0, KEY_READ, &subkey)) { + EnvironmentInfo *env = newEnvironmentInfo(company, buffer); + env->internalSortKey = sortKey; + exitCode = _registryReadEnvironment(search, subkey, env, fallbackArch); + RegCloseKey(subkey); + if (exitCode == RC_NO_PYTHON) { + freeEnvironmentInfo(env); + exitCode = 0; + } else if (!exitCode) { + exitCode = addEnvironmentInfo(result, env); + if (exitCode == RC_DUPLICATE_ITEM) { + exitCode = 0; + } else if (exitCode) { + freeEnvironmentInfo(env); + } + } + } + } + return exitCode; +} + + +int +registrySearch(const SearchInfo *search, EnvironmentInfo **result, HKEY root, int sortKey, const wchar_t *fallbackArch) +{ + wchar_t buffer[256]; + int err = 0; + int exitCode = 0; + for (int i = 0; exitCode == 0; ++i) { + DWORD cchBuffer = sizeof(buffer) / sizeof(buffer[0]); + err = RegEnumKeyExW(root, i, buffer, &cchBuffer, NULL, NULL, NULL, NULL); + if (err) { + if (err != ERROR_NO_MORE_ITEMS) { + winerror(0, L"Failed to read distributors (company) from the registry"); + } + break; + } + HKEY subkey; + if (ERROR_SUCCESS == RegOpenKeyExW(root, buffer, 0, KEY_READ, &subkey)) { + exitCode = _registrySearchTags(search, result, subkey, sortKey, buffer, fallbackArch); + RegCloseKey(subkey); + } + } + return exitCode; +} + + +/******************************************************************************\ + *** APP PACKAGE SEARCH *** +\******************************************************************************/ + +int +appxSearch(const SearchInfo *search, EnvironmentInfo **result, const wchar_t *packageFamilyName, const wchar_t *tag, int sortKey) +{ + wchar_t realTag[32]; + wchar_t buffer[MAXLEN]; + const wchar_t *exeName = search->executable; + if (!exeName || search->allowExecutableOverride) { + exeName = search->windowed ? L"pythonw.exe" : L"python.exe"; + } + + if (FAILED(SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, buffer)) || + !join(buffer, MAXLEN, L"Microsoft\\WindowsApps") || + !join(buffer, MAXLEN, packageFamilyName) || + !join(buffer, MAXLEN, exeName)) { + return RC_INTERNAL_ERROR; + } + + if (INVALID_FILE_ATTRIBUTES == GetFileAttributesW(buffer)) { + return RC_NO_PYTHON; + } + + // Assume packages are native architecture, which means we need to append + // the '-arm64' on ARM64 host. + if (isARM64Host()) { + if (!wcscpy_s(realTag, 32, tag) && !wcscat_s(realTag, 32, L"-arm64")) { + tag = realTag; + } + } + + EnvironmentInfo *env = newEnvironmentInfo(L"PythonCore", tag); + if (!env) { + return RC_NO_MEMORY; + } + env->internalSortKey = sortKey; + if (isAMD64Host()) { + copyWstr(&env->architecture, L"64bit"); + } else if (isARM64Host()) { + copyWstr(&env->architecture, L"ARM64"); + } + + copyWstr(&env->executablePath, buffer); + int exitCode = addEnvironmentInfo(result, env); + if (exitCode == RC_DUPLICATE_ITEM) { + exitCode = 0; + } else if (exitCode) { + freeEnvironmentInfo(env); + } + + return 0; +} + + +/******************************************************************************\ + *** COLLECT ENVIRONMENTS *** +\******************************************************************************/ + + +struct RegistrySearchInfo { + // Registry subkey to search + const wchar_t *subkey; + // Registry hive to search + HKEY hive; + // Flags to use when opening the subkey + DWORD flags; + // Internal sort key to select between "identical" environments discovered + // through different methods + int sortKey; + // Fallback value to assume for PythonCore entries missing a SysArchitecture value + const wchar_t *fallbackArch; +}; + +struct RegistrySearchInfo REGISTRY_SEARCH[] = { + { + L"Software\\Python", + HKEY_CURRENT_USER, + KEY_READ, + 1, + NULL + }, + { + L"Software\\Python", + HKEY_LOCAL_MACHINE, + KEY_READ | KEY_WOW64_64KEY, + 3, + L"64bit" + }, + { + L"Software\\Python", + HKEY_LOCAL_MACHINE, + KEY_READ | KEY_WOW64_32KEY, + 4, + L"32bit" + }, + { NULL, 0, 0, 0, NULL } +}; + + +struct AppxSearchInfo { + // The package family name. Can be found for an installed package using the + // Powershell "Get-AppxPackage" cmdlet + const wchar_t *familyName; + // The tag to treat the installation as + const wchar_t *tag; + // Internal sort key to select between "identical" environments discovered + // through different methods + int sortKey; +}; + + +struct AppxSearchInfo APPX_SEARCH[] = { + // Releases made through the Store + { L"PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0", L"3.11", 10 }, + { L"PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0", L"3.10", 10 }, + { L"PythonSoftwareFoundation.Python.3.9_qbz5n2kfra8p0", L"3.9", 10 }, + { L"PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0", L"3.8", 10 }, + + // Side-loadable releases. Note that the publisher ID changes whenever we + // renew our code-signing certificate, so the newer ID has a higher + // priority (lower sortKey) + { L"PythonSoftwareFoundation.Python.3.11_3847v3x7pw1km", L"3.11", 11 }, + { L"PythonSoftwareFoundation.Python.3.11_hd69rhyc2wevp", L"3.11", 12 }, + { L"PythonSoftwareFoundation.Python.3.10_3847v3x7pw1km", L"3.10", 11 }, + { L"PythonSoftwareFoundation.Python.3.10_hd69rhyc2wevp", L"3.10", 12 }, + { L"PythonSoftwareFoundation.Python.3.9_3847v3x7pw1km", L"3.9", 11 }, + { L"PythonSoftwareFoundation.Python.3.9_hd69rhyc2wevp", L"3.9", 12 }, + { L"PythonSoftwareFoundation.Python.3.8_hd69rhyc2wevp", L"3.8", 12 }, + { NULL, NULL, 0 } +}; + + +int +collectEnvironments(const SearchInfo *search, EnvironmentInfo **result) +{ + int exitCode = 0; + EnvironmentInfo *env = NULL; + if (!result) { + return RC_INTERNAL_ERROR; + } + *result = NULL; + + if (search->executablePath) { + env = newEnvironmentInfo(NULL, NULL); + return copyWstr(&env->executablePath, search->executablePath); + } + + HKEY root; + + for (struct RegistrySearchInfo *info = REGISTRY_SEARCH; info->subkey; ++info) { + if (ERROR_SUCCESS == RegOpenKeyExW(info->hive, info->subkey, 0, info->flags, &root)) { + exitCode = registrySearch(search, result, root, info->sortKey, info->fallbackArch); + RegCloseKey(root); + } + if (exitCode) { + return exitCode; + } + } + + for (struct AppxSearchInfo *info = APPX_SEARCH; info->familyName; ++info) { + exitCode = appxSearch(search, result, info->familyName, info->tag, info->sortKey); + if (exitCode && exitCode != RC_NO_PYTHON) { + return exitCode; + } + } + + return 0; +} + + +/******************************************************************************\ + *** INSTALL ON DEMAND *** +\******************************************************************************/ + +struct StoreSearchInfo { + // The tag a user is looking for + const wchar_t *tag; + // The Store ID for a package if it can be installed from the Microsoft + // Store. These are obtained from the dashboard at + // https://partner.microsoft.com/dashboard + const wchar_t *storeId; +}; + + +struct StoreSearchInfo STORE_SEARCH[] = { + { L"3", /* 3.10 */ L"9PJPW5LDXLZ5" }, + { L"3.11", L"9NRWMJP3717K" }, + { L"3.10", L"9PJPW5LDXLZ5" }, + { L"3.9", L"9P7QFQMJRFP7" }, + { L"3.8", L"9MSSZTT1N39L" }, + { NULL, NULL } +}; + + +int +_installEnvironment(const wchar_t *command, const wchar_t *arguments) +{ + SHELLEXECUTEINFOW siw = { + sizeof(SHELLEXECUTEINFOW), + SEE_MASK_NOASYNC | SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE, + NULL, NULL, + command, arguments, NULL, + SW_SHOWNORMAL + }; + + debug(L"# Installing with %s %s\n", command, arguments); + if (isEnvVarSet(L"PYLAUNCHER_DRYRUN")) { + debug(L"# Exiting due to PYLAUNCHER_DRYRUN\n"); + if (arguments) { + fwprintf_s(stdout, L"\"%s\" %s\n", command, arguments); + } else { + fwprintf_s(stdout, L"\"%s\"\n", command); + } + return RC_INSTALLING; + } + + if (!ShellExecuteExW(&siw)) { + return RC_NO_PYTHON; + } + + if (!siw.hProcess) { + return RC_INSTALLING; + } + + WaitForSingleObjectEx(siw.hProcess, INFINITE, FALSE); + DWORD exitCode = 0; + if (GetExitCodeProcess(siw.hProcess, &exitCode) && exitCode == 0) { + return 0; + } + return RC_INSTALLING; +} + +#define WINGET_COMMAND L"Microsoft\\WindowsApps\\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\\winget.exe" +#define WINGET_ARGUMENTS L"install -q %s --exact --accept-package-agreements --source msstore" + +#define MSSTORE_COMMAND L"ms-windows-store://pdp/?productid=%s" + +int +installEnvironment(const SearchInfo *search) +{ + // No tag? No installing + if (!search->tag || !search->tagLength) { + debug(L"# Cannot install Python with no tag specified\n"); + return RC_NO_PYTHON; + } + + // PEP 514 tag but not PythonCore? No installing + if (!search->oldStyleTag && + search->company && search->companyLength && + 0 != _compare(search->company, search->companyLength, L"PythonCore", -1)) { + debug(L"# Cannot install for company %.*s\n", search->companyLength, search->company); + return RC_NO_PYTHON; + } + + const wchar_t *storeId = NULL; + for (struct StoreSearchInfo *info = STORE_SEARCH; info->tag; ++info) { + if (0 == _compare(search->tag, search->tagLength, info->tag, -1)) { + storeId = info->storeId; + break; + } + } + + if (!storeId) { + return RC_NO_PYTHON; + } + + int exitCode; + wchar_t command[MAXLEN]; + wchar_t arguments[MAXLEN]; + if (SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, command)) && + join(command, MAXLEN, WINGET_COMMAND) && + swprintf_s(arguments, MAXLEN, WINGET_ARGUMENTS, storeId)) { + if (INVALID_FILE_ATTRIBUTES == GetFileAttributesW(command)) { + formatWinerror(GetLastError(), arguments, MAXLEN); + debug(L"# Skipping %s: %s\n", command, arguments); + } else { + fputws(L"Launching winget to install Python. The following output is from the install process\n\ +***********************************************************************\n", stdout); + exitCode = _installEnvironment(command, arguments); + if (exitCode == RC_INSTALLING) { + fputws(L"***********************************************************************\n\ +Please check the install status and run your command again.", stderr); + return exitCode; + } else if (exitCode) { + return exitCode; + } + fputws(L"***********************************************************************\n\ +Install appears to have succeeded. Searching for new matching installs.\n", stdout); + return 0; + } + } + + if (swprintf_s(command, MAXLEN, MSSTORE_COMMAND, storeId)) { + fputws(L"Opening the Microsoft Store to install Python. After installation, " + L"please run your command again.\n", stderr); + exitCode = _installEnvironment(command, NULL); + if (exitCode) { + return exitCode; + } + return 0; + } + + return RC_NO_PYTHON; +} + +/******************************************************************************\ + *** ENVIRONMENT SELECT *** +\******************************************************************************/ + +bool +_companyMatches(const SearchInfo *search, const EnvironmentInfo *env) +{ + if (!search->company || !search->companyLength) { + return true; + } + return 0 == _compare(env->company, -1, search->company, search->companyLength); +} + + +bool +_is32Bit(const EnvironmentInfo *env) +{ + if (env->architecture) { + return 0 == _compare(env->architecture, -1, L"32bit", -1); + } + return false; +} + + +int +_selectEnvironment(const SearchInfo *search, EnvironmentInfo *env, EnvironmentInfo **best) +{ + int exitCode = 0; + while (env) { + exitCode = _selectEnvironment(search, env->prev, best); + + if (exitCode && exitCode != RC_NO_PYTHON) { + return exitCode; + } else if (!exitCode && *best) { + return 0; + } + + if (!search->oldStyleTag) { + if (_companyMatches(search, env) && 0 == _compare(env->tag, -1, search->tag, search->tagLength)) { + *best = env; + return 0; + } + } else { + if (0 == _compare(env->company, -1, L"PythonCore", -1) && + _startsWith(env->tag, -1, search->tag, search->tagLength)) { + if (search->exclude32Bit && _is32Bit(env)) { + debug(L"# Excluding %s/%s because it looks like 32bit\n", env->company, env->tag); + } else { + *best = env; + return 0; + } + } + } + + env = env->next; + } + return RC_NO_PYTHON; +} + +int +selectEnvironment(const SearchInfo *search, EnvironmentInfo *root, EnvironmentInfo **best) +{ + if (!best) { + return RC_INTERNAL_ERROR; + } + if (!root) { + *best = NULL; + return 0; + } + if (!root->next && !root->prev) { + *best = root; + return 0; + } + + EnvironmentInfo *result = NULL; + int exitCode = _selectEnvironment(search, root, &result); + if (!exitCode) { + *best = result; + } + + return exitCode; +} + + +/******************************************************************************\ + *** LIST ENVIRONMENTS *** +\******************************************************************************/ + +#define TAGWIDTH 16 + +int +_printEnvironment(const EnvironmentInfo *env, FILE *out, bool showPath, const wchar_t *argument) +{ + if (showPath) { + if (env->executablePath && env->executablePath[0]) { + if (env->executableArgs && env->executableArgs[0]) { + fwprintf(out, L" %-*s %s %s\n", TAGWIDTH, argument, env->executablePath, env->executableArgs); + } else { + fwprintf(out, L" %-*s %s\n", TAGWIDTH, argument, env->executablePath); + } + } else if (env->installDir && env->installDir[0]) { + fwprintf(out, L" %-*s %s\n", TAGWIDTH, argument, env->installDir); + } else { + fwprintf(out, L" %s\n", argument); + } + } else { + fwprintf(out, L" %s\n", argument); + } + return 0; +} + + +int +_listAllEnvironments(EnvironmentInfo *env, FILE * out, bool showPath, bool *isDefault) +{ + wchar_t buffer[256]; + while (env) { + int exitCode = _listAllEnvironments(env->prev, out, showPath, isDefault); + if (exitCode) { + return exitCode; + } + + if (!env->company || !env->tag) { + buffer[0] = L'\0'; + } else if (0 == _compare(env->company, -1, L"PythonCore", -1)) { + swprintf_s(buffer, sizeof(buffer) / sizeof(buffer[0]), L"-V:%s", env->tag); + } else { + swprintf_s(buffer, sizeof(buffer) / sizeof(buffer[0]), L"-V:%s/%s", env->company, env->tag); + } + if (buffer[0]) { + if (*isDefault) { + wcscat_s(buffer, sizeof(buffer) / sizeof(buffer[0]), L" *"); + *isDefault = false; + } + exitCode = _printEnvironment(env, out, showPath, buffer); + if (exitCode) { + return exitCode; + } + } + + env = env->next; + } + return 0; +} + + +int +listEnvironments(EnvironmentInfo *env, FILE * out, bool showPath) +{ + bool isDefault = true; + + if (!env) { + return 0; + } + + /* TODO: Do we want to display these? + In favour, helps users see that '-3' is a good option + Against, repeats the next line of output + SearchInfo majorSearch; + EnvironmentInfo *major; + int exitCode; + + if (showPath) { + memset(&majorSearch, 0, sizeof(majorSearch)); + majorSearch.company = L"PythonCore"; + majorSearch.companyLength = -1; + majorSearch.tag = L"3"; + majorSearch.tagLength = -1; + majorSearch.oldStyleTag = true; + major = NULL; + exitCode = selectEnvironment(&majorSearch, env, &major); + if (!exitCode && major) { + exitCode = _printEnvironment(major, out, showPath, L"-3 *"); + isDefault = false; + if (exitCode) { + return exitCode; + } + } + majorSearch.tag = L"2"; + major = NULL; + exitCode = selectEnvironment(&majorSearch, env, &major); + if (!exitCode && major) { + exitCode = _printEnvironment(major, out, showPath, L"-2"); + if (exitCode) { + return exitCode; + } + } + } + */ + + return _listAllEnvironments(env, out, showPath, &isDefault); +} + + +/******************************************************************************\ + *** INTERPRETER LAUNCH *** +\******************************************************************************/ + + +int +calculateCommandLine(const SearchInfo *search, const EnvironmentInfo *launch, wchar_t *buffer, int bufferLength) +{ + int exitCode = 0; + const wchar_t *executablePath = NULL; + + // Construct command line from a search override, or else the selected + // environment's executablePath + if (search->executablePath) { + executablePath = search->executablePath; + } else if (launch && launch->executablePath) { + executablePath = launch->executablePath; + } + + // If we have an executable path, put it at the start of the command, but + // only if the search allowed an override. + // Otherwise, use the environment's installDir and the search's default + // executable name. + if (executablePath && search->allowExecutableOverride) { + if (wcschr(executablePath, L' ') && executablePath[0] != L'"') { + buffer[0] = L'"'; + exitCode = wcscpy_s(&buffer[1], bufferLength - 1, executablePath); + if (!exitCode) { + exitCode = wcscat_s(buffer, bufferLength, L"\""); + } + } else { + exitCode = wcscpy_s(buffer, bufferLength, executablePath); + } + } else if (launch) { + if (!launch->installDir) { + fwprintf_s(stderr, L"Cannot launch %s %s because no install directory was specified", + launch->company, launch->tag); + exitCode = RC_NO_PYTHON; + } else if (!search->executable || !search->executableLength) { + fwprintf_s(stderr, L"Cannot launch %s %s because no executable name is available", + launch->company, launch->tag); + exitCode = RC_NO_PYTHON; + } else { + wchar_t executable[256]; + wcsncpy_s(executable, 256, search->executable, search->executableLength); + if ((wcschr(launch->installDir, L' ') && launch->installDir[0] != L'"') || + (wcschr(executable, L' ') && executable[0] != L'"')) { + buffer[0] = L'"'; + exitCode = wcscpy_s(&buffer[1], bufferLength - 1, launch->installDir); + if (!exitCode) { + exitCode = join(buffer, bufferLength, executable) ? 0 : RC_NO_MEMORY; + } + if (!exitCode) { + exitCode = wcscat_s(buffer, bufferLength, L"\""); + } + } else { + exitCode = wcscpy_s(buffer, bufferLength, launch->installDir); + if (!exitCode) { + exitCode = join(buffer, bufferLength, executable) ? 0 : RC_NO_MEMORY; + } + } + } + } else { + exitCode = RC_NO_PYTHON; + } + + if (!exitCode && launch && launch->executableArgs) { + exitCode = wcscat_s(buffer, bufferLength, L" "); + if (!exitCode) { + exitCode = wcscat_s(buffer, bufferLength, launch->executableArgs); + } + } + + if (!exitCode && search->restOfCmdLine) { + exitCode = wcscat_s(buffer, bufferLength, search->restOfCmdLine); + } + + return exitCode; +} + + + +BOOL +_safeDuplicateHandle(HANDLE in, HANDLE * pout, const wchar_t *nameForError) +{ + BOOL ok; + HANDLE process = GetCurrentProcess(); + DWORD rc; + + *pout = NULL; + ok = DuplicateHandle(process, in, process, pout, 0, TRUE, + DUPLICATE_SAME_ACCESS); + if (!ok) { + rc = GetLastError(); + if (rc == ERROR_INVALID_HANDLE) { + debug(L"DuplicateHandle returned ERROR_INVALID_HANDLE\n"); + ok = TRUE; + } + else { + winerror(0, L"Failed to duplicate %s handle", nameForError); + } + } + return ok; +} + +BOOL WINAPI +ctrl_c_handler(DWORD code) +{ + return TRUE; /* We just ignore all control events. */ +} + + +int +launchEnvironment(const SearchInfo *search, const EnvironmentInfo *launch, wchar_t *launchCommand) +{ + HANDLE job; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION info; + DWORD rc; + BOOL ok; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + +#if defined(_WINDOWS) + /* + When explorer launches a Windows (GUI) application, it displays + the "app starting" (the "pointer + hourglass") cursor for a number + of seconds, or until the app does something UI-ish (eg, creating a + window, or fetching a message). As this launcher doesn't do this + directly, that cursor remains even after the child process does these + things. We avoid that by doing a simple post+get message. + See http://bugs.python.org/issue17290 and + https://bitbucket.org/vinay.sajip/pylauncher/issue/20/busy-cursor-for-a-long-time-when-running + */ + MSG msg; + + PostMessage(0, 0, 0, 0); + GetMessage(&msg, 0, 0, 0); +#endif + + debug(L"# about to run: %s\n", launchCommand); + job = CreateJobObject(NULL, NULL); + ok = QueryInformationJobObject(job, JobObjectExtendedLimitInformation, + &info, sizeof(info), &rc); + if (!ok || (rc != sizeof(info)) || !job) { + winerror(0, L"Failed to query job information"); + return RC_CREATE_PROCESS; + } + info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | + JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK; + ok = SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, + sizeof(info)); + if (!ok) { + winerror(0, L"Failed to update job information"); + return RC_CREATE_PROCESS; + } + memset(&si, 0, sizeof(si)); + GetStartupInfoW(&si); + if (!_safeDuplicateHandle(GetStdHandle(STD_INPUT_HANDLE), &si.hStdInput, L"stdin") || + !_safeDuplicateHandle(GetStdHandle(STD_OUTPUT_HANDLE), &si.hStdOutput, L"stdout") || + !_safeDuplicateHandle(GetStdHandle(STD_ERROR_HANDLE), &si.hStdError, L"stderr")) { + return RC_NO_STD_HANDLES; + } + + ok = SetConsoleCtrlHandler(ctrl_c_handler, TRUE); + if (!ok) { + winerror(0, L"Failed to update Control-C handler"); + return RC_NO_STD_HANDLES; + } + + si.dwFlags = STARTF_USESTDHANDLES; + ok = CreateProcessW(NULL, launchCommand, NULL, NULL, TRUE, + 0, NULL, NULL, &si, &pi); + if (!ok) { + winerror(0, L"Unable to create process using '%s'", launchCommand); + return RC_CREATE_PROCESS; + } + AssignProcessToJobObject(job, pi.hProcess); + CloseHandle(pi.hThread); + WaitForSingleObjectEx(pi.hProcess, INFINITE, FALSE); + ok = GetExitCodeProcess(pi.hProcess, &rc); + if (!ok) { + winerror(0, L"Failed to get exit code of process"); + return RC_CREATE_PROCESS; + } + debug(L"child process exit code: %d\n", rc); + return rc; +} + + +/******************************************************************************\ + *** PROCESS CONTROLLER *** +\******************************************************************************/ + + +int +performSearch(SearchInfo *search, EnvironmentInfo **envs) +{ + // First parse the command line for options + int exitCode = parseCommandLine(search); + if (exitCode) { + return exitCode; + } + + // Check for a shebang line in our script file (or exit quickly if no script + // file was specified) + exitCode = checkShebang(search); + if (exitCode) { + return exitCode; + } + + exitCode = checkDefaults(search); + if (exitCode) { + return exitCode; + } + + // If debugging is enabled, list our search criteria + dumpSearchInfo(search); + + // Find all matching environments + exitCode = collectEnvironments(search, envs); + if (exitCode) { + return exitCode; + } + + return 0; +} + + +int +process(int argc, wchar_t ** argv) +{ + int exitCode = 0; + SearchInfo search = {0}; + EnvironmentInfo *envs = NULL; + EnvironmentInfo *env = NULL; + wchar_t launchCommand[MAXLEN]; + + memset(launchCommand, 0, sizeof(launchCommand)); + + if (isEnvVarSet(L"PYLAUNCHER_DEBUG")) { + setvbuf(stderr, (char *)NULL, _IONBF, 0); + log_fp = stderr; + debug(L"argv0: %s\nversion: %S\n", argv[0], PY_VERSION); + } + + search.originalCmdLine = GetCommandLineW(); + + exitCode = performSearch(&search, &envs); + if (exitCode) { + goto abort; + } + + // Display the help text, but only exit on error + if (search.help) { + exitCode = showHelpText(argv); + if (exitCode) { + goto abort; + } + } + + // List all environments, then exit + if (search.list || search.listPaths) { + exitCode = listEnvironments(envs, stdout, search.listPaths); + goto abort; + } + + // When debugging, list all discovered environments anyway + if (log_fp) { + exitCode = listEnvironments(envs, log_fp, true); + if (exitCode) { + goto abort; + } + } + + // Select best environment + env = NULL; + exitCode = selectEnvironment(&search, envs, &env); + // If none found, and if permitted, install it + if (exitCode == RC_NO_PYTHON && isEnvVarSet(L"PYLAUNCHER_ALLOW_INSTALL")) { + exitCode = installEnvironment(&search); + if (!exitCode) { + // Successful install, so we need to re-scan and select again + exitCode = performSearch(&search, &envs); + if (exitCode) { + goto abort; + } + env = NULL; + exitCode = selectEnvironment(&search, envs, &env); + } + } + if (exitCode == RC_NO_PYTHON) { + fputws(L"No suitable Python runtime found\n", stderr); + fputws(L"Pass --list (-0) to see all detected environments on your machine\n", stderr); + if (!isEnvVarSet(L"PYLAUNCHER_ALLOW_INSTALL") && search.oldStyleTag) { + fputws(L"or set environment variable PYLAUNCHER_ALLOW_INSTALL to use winget\n\ +or open the Microsoft Store to the requested version.\n", stderr); + } + goto abort; + } + if (exitCode) { + goto abort; + } + + exitCode = calculateCommandLine(&search, env, launchCommand, sizeof(launchCommand) / sizeof(launchCommand[0])); + if (exitCode) { + goto abort; + } + + // For testing purposes, do not actually launch + if (isEnvVarSet(L"PYLAUNCHER_DRYRUN")) { + debug(L"LaunchCommand: %s\n", launchCommand); + debug(L"# Exiting due to PYLAUNCHER_DRYRUN variable\n"); + fwprintf(stdout, L"%s\n", launchCommand); + goto abort; + } + + // Launch selected runtime + exitCode = launchEnvironment(&search, env, launchCommand); + +abort: + freeSearchInfo(&search); + freeEnvironmentInfo(envs); + return exitCode; +} + + +#if defined(_WINDOWS) + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, + LPWSTR lpstrCmd, int nShow) +{ + return process(__argc, __wargv); +} + +#else + +int cdecl wmain(int argc, wchar_t ** argv) +{ + return process(argc, argv); +} + +#endif diff --git a/PC/pylauncher.rc b/PC/pylauncher.rc index ff7e71e0fdb4e1..11862643aa6989 100644 --- a/PC/pylauncher.rc +++ b/PC/pylauncher.rc @@ -25,6 +25,9 @@ 7 ICON DISCARDABLE "icons\setup.ico" #endif +1 USAGE "launcher-usage.txt" + + ///////////////////////////////////////////////////////////////////////////// // // Version diff --git a/PCbuild/pylauncher.vcxproj b/PCbuild/pylauncher.vcxproj index 550e0842300fb9..35f2f7e505bf92 100644 --- a/PCbuild/pylauncher.vcxproj +++ b/PCbuild/pylauncher.vcxproj @@ -76,7 +76,7 @@ Application - MultiByte + Unicode @@ -95,12 +95,12 @@ MultiThreaded - version.lib;%(AdditionalDependencies) + shell32.lib;pathcch.lib;%(AdditionalDependencies) Console - + diff --git a/PCbuild/pywlauncher.vcxproj b/PCbuild/pywlauncher.vcxproj index 44e3fc29272352..e50b69aefe2b9c 100644 --- a/PCbuild/pywlauncher.vcxproj +++ b/PCbuild/pywlauncher.vcxproj @@ -95,12 +95,12 @@ MultiThreaded - version.lib;%(AdditionalDependencies) + shell32.lib;pathcch.lib;%(AdditionalDependencies) Windows - + From f8a4d094268b6285ecdcfdba07a7783455849711 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 22 Mar 2022 22:17:32 +0000 Subject: [PATCH 02/13] Fix argument calculation --- PC/launcher-usage.txt | 31 +++++++++++++++++++++++++++++++ PC/launcher2.c | 20 ++++++++++++-------- 2 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 PC/launcher-usage.txt diff --git a/PC/launcher-usage.txt b/PC/launcher-usage.txt new file mode 100644 index 00000000000000..6ed5519cb88a99 --- /dev/null +++ b/PC/launcher-usage.txt @@ -0,0 +1,31 @@ +Python Launcher for Windows Version %s + +usage: +%s [launcher-args] [python-args] [script [script-args]] + +Launcher arguments: +-2 : Launch the latest Python 2.x version +-3 : Launch the latest Python 3.x version +-X.Y : Launch the specified Python version + +The above default to an architecture native runtime, but will select any +available. Add a "-32" to the argument to only launch 32-bit runtimes, +or add "-64" to omit 32-bit runtimes (this latter option is deprecated). + +To select a specific runtime, use the -V: options. + +-V:TAG : Launch a Python runtime with the specified tag +-V:COMPANY/TAG : Launch a Python runtime from the specified company and + with the specified tag + +-0 --list : List the available pythons +-0p --list-paths : List with paths + +If no options are given but a script is specified, the script is checked for a +shebang line. Otherwise, an active virtual environment or global default will +be selected. + +See https://docs.python.org/using/windows.html#python-launcher-for-windows for +additional configuration. + +The following help text is from %s: diff --git a/PC/launcher2.c b/PC/launcher2.c index ac7ee1f5978968..beb507df13d2ec 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -473,10 +473,10 @@ findArgumentLength(const wchar_t *buffer, int bufferLength) } i = 0; - while(i < bufferLength) { + while (i < bufferLength) { end = wcschr(&buffer[i + 1], L'"'); if (!end) { - return bufferLength + return bufferLength; } i = (int)(end - buffer); @@ -495,8 +495,8 @@ findArgumentLength(const wchar_t *buffer, int bufferLength) } // Non-escaped quote with space after it - end of the argument! - if (j + 1 < bufferLength && isspace(buffer[j + 1])) { - return i; + if (i + 1 >= bufferLength || isspace(buffer[i + 1])) { + return i + 1; } } @@ -507,7 +507,7 @@ findArgumentLength(const wchar_t *buffer, int bufferLength) const wchar_t * findArgumentEnd(const wchar_t *buffer, int bufferLength) { - return &buffer[findArgumentLength(buffer, bufferLength)] + return &buffer[findArgumentLength(buffer, bufferLength)]; } @@ -523,7 +523,7 @@ parseCommandLine(SearchInfo *search) return RC_NO_COMMANDLINE; } - const wchar_t *tail = findArgumentEnd(search->originalCmdLine); + const wchar_t *tail = findArgumentEnd(search->originalCmdLine, -1); const wchar_t *end = tail; search->restOfCmdLine = tail; while (--tail != search->originalCmdLine) { @@ -708,7 +708,9 @@ _readIni(const wchar_t *section, const wchar_t *settingName, wchar_t *buffer, in if (n) { debug(L"# Found %s in %s\n", settingName, iniPath); return true; - } else if (GetLastError() != ERROR_FILE_NOT_FOUND) { + } else if (GetLastError() == ERROR_FILE_NOT_FOUND) { + debug(L"# Did not find file %s\n", iniPath); + } else { winerror(0, L"Failed to read from %s\n", iniPath); } } @@ -720,7 +722,9 @@ _readIni(const wchar_t *section, const wchar_t *settingName, wchar_t *buffer, in if (n) { debug(L"# Found %s in %s\n", settingName, iniPath); return n; - } else if (GetLastError() != ERROR_FILE_NOT_FOUND) { + } else if (GetLastError() == ERROR_FILE_NOT_FOUND) { + debug(L"# Did not find file %s\n", iniPath); + } else { winerror(0, L"Failed to read from %s\n", iniPath); } } From 9a85392be12b75c79fcd000bbf160274a2bac934 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 23 Mar 2022 00:24:55 +0000 Subject: [PATCH 03/13] Improved tests and sort order --- Lib/test/test_launcher.py | 125 ++++++++++++++++++++++++++++++++++---- PC/launcher-usage.txt | 2 +- PC/launcher2.c | 27 +++++++- 3 files changed, 138 insertions(+), 16 deletions(-) diff --git a/Lib/test/test_launcher.py b/Lib/test/test_launcher.py index aacf58a307601e..d6405d959dacd5 100644 --- a/Lib/test/test_launcher.py +++ b/Lib/test/test_launcher.py @@ -25,26 +25,34 @@ "PythonTestSuite": { "DisplayName": "Python Test Suite", "SupportUrl": "https://www.python.org/", - "{0.major}.{0.minor}".format(sys.version_info): { + "3.100": { "DisplayName": "X.Y version", "InstallPath": { None: sys.prefix, + "ExecutablePath": "X.Y.exe", } }, - "{0.major}.{0.minor}-32".format(sys.version_info): { + "3.100-32": { "DisplayName": "X.Y-32 version", "InstallPath": { None: sys.prefix, + "ExecutablePath": "X.Y-32.exe", } }, - "{0.major}.{0.minor}-arm64".format(sys.version_info): { + "3.100-arm64": { "DisplayName": "X.Y-arm64 version", "InstallPath": { None: sys.prefix, - "ExecutablePath": sys.executable, + "ExecutablePath": "X.Y-arm64.exe", "ExecutableArguments": "-X fake_arg_for_test", } }, + "ignored": { + "DisplayName": "Ignored because no ExecutablePath", + "InstallPath": { + None: sys.prefix, + } + }, } } @@ -75,6 +83,7 @@ def enum_keys(root): break raise + def delete_registry_data(root, keys): ACCESS = winreg.KEY_WRITE | winreg.KEY_ENUMERATE_SUB_KEYS for key in list(keys): @@ -83,12 +92,29 @@ def delete_registry_data(root, keys): winreg.DeleteKey(root, key) +def is_installed(tag): + key = rf"Software\Python\PythonCore\{tag}\InstallPath" + for root, flag in [ + (winreg.HKEY_CURRENT_USER, 0), + (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY), + (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY), + ]: + try: + winreg.CloseKey(winreg.OpenKey(root, key, access=winreg.KEY_READ | flag)) + return True + except OSError: + pass + return False + + class TestLauncher(unittest.TestCase): + py_exe = None + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.py_exe = None - def setUp(self): + @classmethod + def setUpClass(cls): py_exe = None if sysconfig.is_python_build(True): py_exe = Path(sys.executable).parent / PY_EXE @@ -102,18 +128,23 @@ def setUp(self): raise unittest.SkipTest( "cannot locate '{}' for test".format(PY_EXE) ) - self.py_exe = py_exe + cls.py_exe = py_exe with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Python") as key: create_registry_data(key, TEST_DATA) + if support.verbose: + p = subprocess.check_output("reg query HKCU\\Software\\Python /s") + print(p.decode('mbcs')) + - def tearDown(self): + @classmethod + def tearDownClass(cls): with winreg.OpenKey(winreg.HKEY_CURRENT_USER, rf"Software\Python", access=winreg.KEY_WRITE | winreg.KEY_ENUMERATE_SUB_KEYS) as key: delete_registry_data(key, TEST_DATA) - def run_py(self, args, env=None): + def run_py(self, args, env=None, allow_fail=False): env = {**os.environ, **(env or {}), "PYLAUNCHER_DEBUG": "1", "PYLAUNCHER_DRYRUN": "1"} with subprocess.Popen( [self.py_exe, *args], @@ -126,14 +157,17 @@ def run_py(self, args, env=None): p.wait(10) out = p.stdout.read().decode("ascii", "replace") err = p.stderr.read().decode("ascii", "replace") - if p.returncode and support.verbose: + if p.returncode and support.verbose and not allow_fail: print("++ COMMAND ++") print([self.py_exe, *args]) print("++ STDOUT ++") print(out) print("++ STDERR ++") print(err) - self.assertEqual(0, p.returncode) + if allow_fail and p.returncode: + raise subprocess.CalledProcessError(p.returncode, [self.py_exe, *args], out, err) + else: + self.assertEqual(0, p.returncode) data = { s.partition(":")[0]: s.partition(":")[2].lstrip() for s in err.splitlines() @@ -188,5 +222,72 @@ def test_list(self): except KeyError: expect[arg] = str(Path(install[None]) / Path(sys.executable).name) + expect.pop(f"-V:{company}/ignored", None) + actual = {k: v for k, v in found.items() if k in expect} - self.assertDictEqual(expect, actual) + try: + self.assertDictEqual(expect, actual) + except: + if support.verbose: + print("*** STDOUT ***") + print(data["stdout"]) + raise + + def test_filter_to_company(self): + company = "PythonTestSuite" + data = self.run_py([f"-V:{company}/"]) + self.assertEqual("X.Y.exe", data["LaunchCommand"]) + self.assertEqual(company, data["env.company"]) + self.assertEqual("3.100", data["env.tag"]) + + def test_filter_to_tag(self): + company = "PythonTestSuite" + data = self.run_py([f"-V:3.100"]) + self.assertEqual("X.Y.exe", data["LaunchCommand"]) + self.assertEqual(company, data["env.company"]) + self.assertEqual("3.100", data["env.tag"]) + + data = self.run_py([f"-V:3.100-3"]) + self.assertEqual("X.Y-32.exe", data["LaunchCommand"]) + self.assertEqual(company, data["env.company"]) + self.assertEqual("3.100-32", data["env.tag"]) + + data = self.run_py([f"-V:3.100-a"]) + self.assertEqual("X.Y-arm64.exe -X fake_arg_for_test", data["LaunchCommand"]) + self.assertEqual(company, data["env.company"]) + self.assertEqual("3.100-arm64", data["env.tag"]) + + def test_filter_to_company_and_tag(self): + company = "PythonTestSuite" + data = self.run_py([f"-V:{company}/3.1"]) + self.assertEqual("X.Y.exe", data["LaunchCommand"]) + self.assertEqual(company, data["env.company"]) + self.assertEqual("3.100", data["env.tag"]) + + def test_search_major_3(self): + try: + data = self.run_py(["-3"], allow_fail=True) + except subprocess.CalledProcessError: + raise unittest.SkipTest("requires at least one Python 3.x install") + self.assertEqual("PythonCore", data["env.company"]) + self.assertTrue(data["env.tag"].startswith("3."), data["env.tag"]) + + def test_search_major_3_32(self): + try: + data = self.run_py(["-3-32"], allow_fail=True) + except subprocess.CalledProcessError: + if not any(is_installed(f"3.{i}-32") for i in range(5, 11)): + raise unittest.SkipTest("requires at least one 32-bit Python 3.x install") + raise + self.assertEqual("PythonCore", data["env.company"]) + self.assertTrue(data["env.tag"].startswith("3."), data["env.tag"]) + self.assertTrue(data["env.tag"].endswith("-32"), data["env.tag"]) + + def test_search_major_2(self): + try: + data = self.run_py(["-2"], allow_fail=True) + except subprocess.CalledProcessError: + if not is_installed("2.7"): + raise unittest.SkipTest("requires at least one Python 2.x install") + self.assertEqual("PythonCore", data["env.company"]) + self.assertTrue(data["env.tag"].startswith("2."), data["env.tag"]) diff --git a/PC/launcher-usage.txt b/PC/launcher-usage.txt index 6ed5519cb88a99..aad103509daa28 100644 --- a/PC/launcher-usage.txt +++ b/PC/launcher-usage.txt @@ -28,4 +28,4 @@ be selected. See https://docs.python.org/using/windows.html#python-launcher-for-windows for additional configuration. -The following help text is from %s: +The following help text is from Python: diff --git a/PC/launcher2.c b/PC/launcher2.c index beb507df13d2ec..b20b1e3754cf59 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -1047,9 +1047,13 @@ _compareTag(const wchar_t *x, const wchar_t *y) return r; } // If we're equal up to the first dash, we want to sort one with - // no dash *after* one with a dash. Otherwise, a regular compare + // no dash *after* one with a dash. Otherwise, a reversed compare. + // This works out because environments are sorted in descending tag + // order, so that higher versions (probably) come first. + // For PythonCore, our "X.Y" structure ensures that higher versions + // come first. Everyone else will just have to deal with it. if (xDash && yDash) { - return _compare(xDash, -1, yDash, -1); + return _compare(yDash, -1, xDash, -1); } else if (xDash) { return -1; } else if (yDash) { @@ -1361,6 +1365,7 @@ struct RegistrySearchInfo { const wchar_t *fallbackArch; }; + struct RegistrySearchInfo REGISTRY_SEARCH[] = { { L"Software\\Python", @@ -1607,6 +1612,16 @@ _companyMatches(const SearchInfo *search, const EnvironmentInfo *env) } +bool +_tagMatches(const SearchInfo *search, const EnvironmentInfo *env) +{ + if (!search->tag || !search->tagLength) { + return true; + } + return _startsWith(env->tag, -1, search->tag, search->tagLength); +} + + bool _is32Bit(const EnvironmentInfo *env) { @@ -1631,7 +1646,11 @@ _selectEnvironment(const SearchInfo *search, EnvironmentInfo *env, EnvironmentIn } if (!search->oldStyleTag) { - if (_companyMatches(search, env) && 0 == _compare(env->tag, -1, search->tag, search->tagLength)) { + if (_companyMatches(search, env) && _tagMatches(search, env)) { + // Because of how our sort tree is set up, we will walk up the + // "prev" side and implicitly select the "best" best. By + // returning straight after a match, we skip the entire "next" + // branch and won't ever select a "worse" best. *best = env; return 0; } @@ -2090,6 +2109,8 @@ or open the Microsoft Store to the requested version.\n", stderr); goto abort; } + debug(L"env.company: %s\nenv.tag: %s\n", env->company, env->tag); + exitCode = calculateCommandLine(&search, env, launchCommand, sizeof(launchCommand) / sizeof(launchCommand[0])); if (exitCode) { goto abort; From 8f5159abf5a0fc3fb30ab4f5dc2721d69b9acc96 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 23 Mar 2022 12:08:48 +0000 Subject: [PATCH 04/13] Support -x-32 old style tags --- PC/launcher2.c | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/PC/launcher2.c b/PC/launcher2.c index b20b1e3754cf59..e0648959dc5392 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -355,6 +355,9 @@ typedef struct { // if true, we had an old-style tag with '-64' suffix, and so do not // want to match tags like '3.x-32' bool exclude32Bit; + // if true, we had an old-style tag with '-32' suffix, and so *only* + // want to match tags like '3.x-32' + bool only32Bit; // if true, allow PEP 514 lookup to override 'executable' bool allowExecutableOverride; // if true, allow a nearby pyvenv.cfg to locate the executable @@ -438,6 +441,7 @@ dumpSearchInfo(SearchInfo *search) DEBUG_2(tag, tagLength); DEBUG_BOOL(oldStyleTag); DEBUG_BOOL(exclude32Bit); + DEBUG_BOOL(only32Bit); DEBUG_BOOL(allowDefaults); DEBUG_BOOL(allowExecutableOverride); DEBUG_BOOL(windowed); @@ -600,10 +604,15 @@ parseCommandLine(SearchInfo *search) search->oldStyleTag = true; search->restOfCmdLine = tail; // If the tag ends with -64, we want to exclude 32-bit runtimes - // (If the tag ends with -32, it will filter fine, so no more is needed) - if (argLen > 3 && 0 == _compareArgument(&arg[argLen - 3], 3, L"-64", 3)) { - search->tagLength -= 3; - search->exclude32Bit = true; + // (If the tag ends with -32, it will be filtered later) + if (argLen > 3) { + if (0 == _compareArgument(&arg[argLen - 3], 3, L"-64", 3)) { + search->tagLength -= 3; + search->exclude32Bit = true; + } else if (0 == _compareArgument(&arg[argLen - 3], 3, L"-32", 3)) { + search->tagLength -= 3; + search->only32Bit = true; + } } } else if (STARTSWITH(L"V:") || STARTSWITH(L"-version:")) { // Arguments starting with 'V:' specify company and/or tag @@ -1654,11 +1663,13 @@ _selectEnvironment(const SearchInfo *search, EnvironmentInfo *env, EnvironmentIn *best = env; return 0; } - } else { - if (0 == _compare(env->company, -1, L"PythonCore", -1) && - _startsWith(env->tag, -1, search->tag, search->tagLength)) { + } else if (0 == _compare(env->company, -1, L"PythonCore", -1)) { + // Old-style tags can only match PythonCore entries + if (_startsWith(env->tag, -1, search->tag, search->tagLength)) { if (search->exclude32Bit && _is32Bit(env)) { debug(L"# Excluding %s/%s because it looks like 32bit\n", env->company, env->tag); + } else if (search->only32Bit && !_is32Bit(env)) { + debug(L"# Excluding %s/%s because it doesn't look 32bit\n", env->company, env->tag); } else { *best = env; return 0; From f1fd7f1b330b3f630d25b26a0caa010db586a44e Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 23 Mar 2022 12:24:30 +0000 Subject: [PATCH 05/13] Fix GetFileAttributes check --- PC/launcher2.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PC/launcher2.c b/PC/launcher2.c index e0648959dc5392..74d39e8124b85f 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -890,7 +890,7 @@ checkDefaults(SearchInfo *search) if (!search->tag || !search->tagLength) { n = GetEnvironmentVariableW(L"VIRTUAL_ENV", buffer, MAXLEN); if (n && join(buffer, MAXLEN, L"Scripts") && join(buffer, MAXLEN, search->executable)) { - if (INVALID_FILE_ATTRIBUTES == GetFileAttributesW(buffer)) { + if (INVALID_FILE_ATTRIBUTES != GetFileAttributesW(buffer)) { n = (int)wcsnlen_s(buffer, MAXLEN) + 1; wchar_t *path = allocSearchInfoBuffer(search, n); if (!path) { From 1fce5e35a1555cc6ace24c61b782b357797013b2 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 23 Mar 2022 12:34:00 +0000 Subject: [PATCH 06/13] Fix VIRTUAL_ENV override --- PC/launcher2.c | 52 ++++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/PC/launcher2.c b/PC/launcher2.c index 74d39e8124b85f..4a4815c5554192 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -879,7 +879,7 @@ checkShebang(SearchInfo *search) int checkDefaults(SearchInfo *search) { - if (!search->allowDefaults) { + if (!search->allowDefaults || search->list || search->listPaths) { return 0; } @@ -2093,34 +2093,36 @@ process(int argc, wchar_t ** argv) // Select best environment env = NULL; - exitCode = selectEnvironment(&search, envs, &env); - // If none found, and if permitted, install it - if (exitCode == RC_NO_PYTHON && isEnvVarSet(L"PYLAUNCHER_ALLOW_INSTALL")) { - exitCode = installEnvironment(&search); - if (!exitCode) { - // Successful install, so we need to re-scan and select again - exitCode = performSearch(&search, &envs); - if (exitCode) { - goto abort; + if (search.executablePath == NULL) { + exitCode = selectEnvironment(&search, envs, &env); + // If none found, and if permitted, install it + if (exitCode == RC_NO_PYTHON && isEnvVarSet(L"PYLAUNCHER_ALLOW_INSTALL")) { + exitCode = installEnvironment(&search); + if (!exitCode) { + // Successful install, so we need to re-scan and select again + exitCode = performSearch(&search, &envs); + if (exitCode) { + goto abort; + } + env = NULL; + exitCode = selectEnvironment(&search, envs, &env); } - env = NULL; - exitCode = selectEnvironment(&search, envs, &env); } - } - if (exitCode == RC_NO_PYTHON) { - fputws(L"No suitable Python runtime found\n", stderr); - fputws(L"Pass --list (-0) to see all detected environments on your machine\n", stderr); - if (!isEnvVarSet(L"PYLAUNCHER_ALLOW_INSTALL") && search.oldStyleTag) { - fputws(L"or set environment variable PYLAUNCHER_ALLOW_INSTALL to use winget\n\ -or open the Microsoft Store to the requested version.\n", stderr); + if (exitCode == RC_NO_PYTHON) { + fputws(L"No suitable Python runtime found\n", stderr); + fputws(L"Pass --list (-0) to see all detected environments on your machine\n", stderr); + if (!isEnvVarSet(L"PYLAUNCHER_ALLOW_INSTALL") && search.oldStyleTag) { + fputws(L"or set environment variable PYLAUNCHER_ALLOW_INSTALL to use winget\n" + L"or open the Microsoft Store to the requested version.\n", stderr); + } + goto abort; + } + if (exitCode) { + goto abort; } - goto abort; - } - if (exitCode) { - goto abort; - } - debug(L"env.company: %s\nenv.tag: %s\n", env->company, env->tag); + debug(L"env.company: %s\nenv.tag: %s\n", env->company, env->tag); + } exitCode = calculateCommandLine(&search, env, launchCommand, sizeof(launchCommand) / sizeof(launchCommand[0])); if (exitCode) { From 0fea6ac260fc85fd6a4630c1353465014baf69f8 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 23 Mar 2022 12:41:08 +0000 Subject: [PATCH 07/13] Fix docs --- Doc/using/windows.rst | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Doc/using/windows.rst b/Doc/using/windows.rst index b2bbd589ec108e..e5cf657f0cae84 100644 --- a/Doc/using/windows.rst +++ b/Doc/using/windows.rst @@ -944,22 +944,29 @@ For example: Diagnostics ----------- -If an environment variable :envvar:`PYLAUNCH_DEBUG` is set (to any value), the +If an environment variable :envvar:`PYLAUNCHER_DEBUG` is set (to any value), the launcher will print diagnostic information to stderr (i.e. to the console). While this information manages to be simultaneously verbose *and* terse, it should allow you to see what versions of Python were located, why a particular version was chosen and the exact command-line used to execute the target Python. It is primarily intended for testing and debugging. +Dry Run +------- + +If an environment variable :envvar:`PYLAUNCHER_DRYRUN` is set (to any value), +the launcher will output the command it would have run, but will not actually +launch Python. This may be useful for tools that want to use the launcher to +detect and then launch Python directly. + Install on demand ----------------- -If an environment variable :envvar:`PYLAUNCH_ALLOW_INSTALL` is set (to any +If an environment variable :envvar:`PYLAUNCHER_ALLOW_INSTALL` is set (to any value), and the requested Python version is not installed but is available on the Microsoft Store, the launcher will attempt to install it. This may require user interaction to complete, and you may need to run the command again. - Return codes ------------ From e11b4c72610b62750da3fbf947448c7434473f28 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 23 Mar 2022 12:51:53 +0000 Subject: [PATCH 08/13] Add NEWS --- .../next/Windows/2022-03-23-12-51-46.bpo-46566.4x4a7e.rst | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Misc/NEWS.d/next/Windows/2022-03-23-12-51-46.bpo-46566.4x4a7e.rst diff --git a/Misc/NEWS.d/next/Windows/2022-03-23-12-51-46.bpo-46566.4x4a7e.rst b/Misc/NEWS.d/next/Windows/2022-03-23-12-51-46.bpo-46566.4x4a7e.rst new file mode 100644 index 00000000000000..b1822872113f7a --- /dev/null +++ b/Misc/NEWS.d/next/Windows/2022-03-23-12-51-46.bpo-46566.4x4a7e.rst @@ -0,0 +1,6 @@ +Upgraded :ref:`launcher` to support a new ``-V:company/tag`` argument for +full :pep:`514` support and to detect ARM64 installs. The ``-64`` suffix on +arguments is deprecated, but still selects any non-32-bit install. Setting +:envvar:`PYLAUNCHER_ALLOW_INSTALL` and specifying a version that is not +installed will attempt to install the requested version from the Microsoft +Store. From 643c270595b4c2eefd21d415576b08fdbf2d7366 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 23 Mar 2022 23:06:11 +0000 Subject: [PATCH 09/13] Add display names, shebang arguments, and tests. --- Doc/using/windows.rst | 6 +- Lib/test/test_launcher.py | 134 ++++++++++++++++++++++++++++++++------ PC/launcher2.c | 72 ++++++++++++++++---- 3 files changed, 178 insertions(+), 34 deletions(-) diff --git a/Doc/using/windows.rst b/Doc/using/windows.rst index e5cf657f0cae84..27a35b3b0823dd 100644 --- a/Doc/using/windows.rst +++ b/Doc/using/windows.rst @@ -957,7 +957,9 @@ Dry Run If an environment variable :envvar:`PYLAUNCHER_DRYRUN` is set (to any value), the launcher will output the command it would have run, but will not actually launch Python. This may be useful for tools that want to use the launcher to -detect and then launch Python directly. +detect and then launch Python directly. Note that the command written to +standard output is always encoded using UTF-8, and may not render correctly in +the console. Install on demand ----------------- @@ -975,7 +977,7 @@ there is no way to distinguish these from the exit code of Python itself. The names of codes are as used in the sources, and are only for reference. There is no way to access or resolve them apart from reading this page. Entries are -listed in alphabetical order of names +listed in alphabetical order of names. +-------------------+-------+-----------------------------------------------+ | Name | Value | Description | diff --git a/Lib/test/test_launcher.py b/Lib/test/test_launcher.py index d6405d959dacd5..c1d3872de2ad3b 100644 --- a/Lib/test/test_launcher.py +++ b/Lib/test/test_launcher.py @@ -1,9 +1,12 @@ +import contextlib import itertools import os import re import subprocess import sys import sysconfig +import tempfile +import textwrap import unittest from pathlib import Path from test import support @@ -56,6 +59,13 @@ } } +TEST_PY_COMMANDS = textwrap.dedent(""" + [defaults] + py_python=PythonTestSuite/3.100 + py_python2=PythonTestSuite/3.100-32 + py_python3=PythonTestSuite/3.100-arm64 +""") + def create_registry_data(root, data): def _create_registry_data(root, key, value): @@ -107,14 +117,31 @@ def is_installed(tag): return False -class TestLauncher(unittest.TestCase): - py_exe = None +class PreservePyIni: + def __init__(self, path, content): + self.path = Path(path) + self.content = content + self._preserved = None + + def __enter__(self): + try: + self._preserved = self.path.read_bytes() + except FileNotFoundError: + self._preserved = None + self.path.write_text(self.content, encoding="utf-16") + + def __exit__(self, *exc_info): + if self._preserved is None: + self.path.unlink() + else: + self.path.write_bytes(self._preserved) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + +class RunPyMixin: + py_exe = None @classmethod - def setUpClass(cls): + def find_py(cls): py_exe = None if sysconfig.is_python_build(True): py_exe = Path(sys.executable).parent / PY_EXE @@ -128,23 +155,12 @@ def setUpClass(cls): raise unittest.SkipTest( "cannot locate '{}' for test".format(PY_EXE) ) - cls.py_exe = py_exe - - with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Python") as key: - create_registry_data(key, TEST_DATA) - - if support.verbose: - p = subprocess.check_output("reg query HKCU\\Software\\Python /s") - print(p.decode('mbcs')) - - - @classmethod - def tearDownClass(cls): - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, rf"Software\Python", access=winreg.KEY_WRITE | winreg.KEY_ENUMERATE_SUB_KEYS) as key: - delete_registry_data(key, TEST_DATA) - + return py_exe def run_py(self, args, env=None, allow_fail=False): + if not self.py_exe: + self.py_exe = self.find_py() + env = {**os.environ, **(env or {}), "PYLAUNCHER_DEBUG": "1", "PYLAUNCHER_DRYRUN": "1"} with subprocess.Popen( [self.py_exe, *args], @@ -155,7 +171,7 @@ def run_py(self, args, env=None, allow_fail=False): ) as p: p.stdin.close() p.wait(10) - out = p.stdout.read().decode("ascii", "replace") + out = p.stdout.read().decode("utf-8", "replace") err = p.stderr.read().decode("ascii", "replace") if p.returncode and support.verbose and not allow_fail: print("++ COMMAND ++") @@ -177,6 +193,37 @@ def run_py(self, args, env=None, allow_fail=False): data["stderr"] = err return data + def py_ini(self, content): + if not self.py_exe: + self.py_exe = self.find_py() + return PreservePyIni(self.py_exe.with_name("py.ini"), content) + + @contextlib.contextmanager + def script(self, content, encoding="utf-8"): + file = Path(tempfile.mktemp(dir=os.getcwd()) + ".py") + file.write_text(content, encoding=encoding) + try: + yield file + finally: + file.unlink() + + +class TestLauncher(unittest.TestCase, RunPyMixin): + @classmethod + def setUpClass(cls): + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Python") as key: + create_registry_data(key, TEST_DATA) + + if support.verbose: + p = subprocess.check_output("reg query HKCU\\Software\\Python /s") + print(p.decode('mbcs')) + + + @classmethod + def tearDownClass(cls): + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, rf"Software\Python", access=winreg.KEY_WRITE | winreg.KEY_ENUMERATE_SUB_KEYS) as key: + delete_registry_data(key, TEST_DATA) + def test_version(self): data = self.run_py(["-0"]) @@ -291,3 +338,48 @@ def test_search_major_2(self): raise unittest.SkipTest("requires at least one Python 2.x install") self.assertEqual("PythonCore", data["env.company"]) self.assertTrue(data["env.tag"].startswith("2."), data["env.tag"]) + + def test_py_default(self): + with self.py_ini(TEST_PY_COMMANDS): + data = self.run_py(["-arg"]) + self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) + self.assertEqual("3.100", data["SearchInfo.tag"]) + self.assertEqual("X.Y.exe -arg", data["stdout"].strip()) + + def test_py2_default(self): + with self.py_ini(TEST_PY_COMMANDS): + data = self.run_py(["-2", "-arg"]) + self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) + self.assertEqual("3.100-32", data["SearchInfo.tag"]) + self.assertEqual("X.Y-32.exe -arg", data["stdout"].strip()) + + def test_py3_default(self): + with self.py_ini(TEST_PY_COMMANDS): + data = self.run_py(["-3", "-arg"]) + self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) + self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) + self.assertEqual("X.Y-arm64.exe -X fake_arg_for_test -arg", data["stdout"].strip()) + + def test_py_shebang(self): + with self.py_ini(TEST_PY_COMMANDS): + with self.script("#! /usr/bin/env python -prearg") as script: + data = self.run_py([script, "-postarg"]) + self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) + self.assertEqual("3.100", data["SearchInfo.tag"]) + self.assertEqual(f"X.Y.exe -prearg {script} -postarg", data["stdout"].strip()) + + def test_py2_shebang(self): + with self.py_ini(TEST_PY_COMMANDS): + with self.script("#! /usr/bin/env python2 -prearg") as script: + data = self.run_py([script, "-postarg"]) + self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) + self.assertEqual("3.100-32", data["SearchInfo.tag"]) + self.assertEqual(f"X.Y-32.exe -prearg {script} -postarg", data["stdout"].strip()) + + def test_py3_shebang(self): + with self.py_ini(TEST_PY_COMMANDS): + with self.script("#! /usr/bin/env python3 -prearg") as script: + data = self.run_py([script, "-postarg"]) + self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) + self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) + self.assertEqual(f"X.Y-arm64.exe -X fake_arg_for_test -prearg {script} -postarg", data["stdout"].strip()) diff --git a/PC/launcher2.c b/PC/launcher2.c index 4a4815c5554192..a9afbc852295a1 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -10,6 +10,8 @@ #include #include +#include +#include #include #include #include @@ -340,6 +342,11 @@ typedef struct { // is null terminated. const wchar_t *executable; int executableLength; + // pointer and length into a string with additional interpreter + // arguments to include before restOfCmdLine. Length can be -1 if + // the string is null terminated. + const wchar_t *executableArgs; + int executableArgsLength; // pointer and length into cmdline or a static string with the // company name for PEP 514 lookup. Length can be -1 if the string // is null terminated. @@ -437,6 +444,7 @@ dumpSearchInfo(SearchInfo *search) DEBUG(executablePath); DEBUG_2(scriptFile, scriptFileLength); DEBUG_2(executable, executableLength); + DEBUG_2(executableArgs, executableArgsLength); DEBUG_2(company, companyLength); DEBUG_2(tag, tagLength); DEBUG_BOOL(oldStyleTag); @@ -832,7 +840,7 @@ checkShebang(SearchInfo *search) while (--bytesRead > 0 && *++b != '\r' && *b != '\n') { } wchar_t *shebang; int shebangLength; - int exitCode = _decodeShebang(search, start, (int)(b - start), onlyUtf8, &shebang, &shebangLength); + int exitCode = _decodeShebang(search, start, (int)(b - start + 1), onlyUtf8, &shebang, &shebangLength); if (exitCode) { return exitCode; } @@ -856,13 +864,17 @@ checkShebang(SearchInfo *search) } if (!commandLength) { } else if (_findCommand(search, command, commandLength)) { + search->executableArgs = &command[commandLength]; + search->executableArgsLength = shebangLength - commandLength; debug(L"# Treating shebang command '%.*s' as %s\n", commandLength, command, search->executablePath); } else if (_shebangStartsWith(command, commandLength, L"python", NULL)) { search->tag = &command[6]; search->tagLength = commandLength - 6; search->oldStyleTag = true; - debug(L"# Treating shebang command '%.*s' as 'py -V:%.*s'\n", + search->executableArgs = &command[commandLength]; + search->executableArgsLength = shebangLength - commandLength; + debug(L"# Treating shebang command '%.*s' as 'py -%.*s'\n", commandLength, command, search->tagLength, search->tag); } else { debug(L"# Found shebang command but could not execute it: %.*s\n", @@ -938,8 +950,17 @@ checkDefaults(SearchInfo *search) return RC_NO_MEMORY; } wcscpy_s(tag, n + 1, buffer); - search->tag = tag; - search->tagLength = n; + wchar_t *slash = wcschr(tag, L'/'); + if (!slash) { + search->tag = tag; + search->tagLength = n; + } else { + search->company = tag; + search->companyLength = (int)(slash - tag); + search->tag = slash + 1; + search->tagLength = n - (search->companyLength + 1); + search->oldStyleTag = false; + } } return 0; @@ -962,6 +983,7 @@ typedef struct EnvironmentInfo { const wchar_t *executablePath; const wchar_t *executableArgs; const wchar_t *architecture; + const wchar_t *displayName; } EnvironmentInfo; @@ -1018,6 +1040,7 @@ freeEnvironmentInfo(EnvironmentInfo *env) free((void *)env->installDir); free((void *)env->executablePath); free((void *)env->executableArgs); + free((void *)env->displayName); freeEnvironmentInfo(env->prev); env->prev = NULL; freeEnvironmentInfo(env->next); @@ -1210,6 +1233,11 @@ _registryReadEnvironment(const SearchInfo *search, HKEY root, EnvironmentInfo *e return exitCode; } + exitCode = _registryReadString(&env->displayName, root, NULL, L"DisplayName"); + if (exitCode) { + return exitCode; + } + // Only PythonCore entries will infer executablePath from installDir and architecture from the binary if (0 == _compare(env->company, -1, L"PythonCore", -1)) { if (!env->executablePath) { @@ -1326,13 +1354,12 @@ appxSearch(const SearchInfo *search, EnvironmentInfo **result, const wchar_t *pa // Assume packages are native architecture, which means we need to append // the '-arm64' on ARM64 host. + wcscpy_s(realTag, 32, tag); if (isARM64Host()) { - if (!wcscpy_s(realTag, 32, tag) && !wcscat_s(realTag, 32, L"-arm64")) { - tag = realTag; - } + wcscat_s(realTag, 32, L"-arm64"); } - EnvironmentInfo *env = newEnvironmentInfo(L"PythonCore", tag); + EnvironmentInfo *env = newEnvironmentInfo(L"PythonCore", realTag); if (!env) { return RC_NO_MEMORY; } @@ -1344,6 +1371,11 @@ appxSearch(const SearchInfo *search, EnvironmentInfo **result, const wchar_t *pa } copyWstr(&env->executablePath, buffer); + + if (swprintf_s(buffer, MAXLEN, L"Python %s (Store)", tag)) { + copyWstr(&env->displayName, buffer); + } + int exitCode = addEnvironmentInfo(result, env); if (exitCode == RC_DUPLICATE_ITEM) { exitCode = 0; @@ -1351,7 +1383,8 @@ appxSearch(const SearchInfo *search, EnvironmentInfo **result, const wchar_t *pa freeEnvironmentInfo(env); } - return 0; + + return exitCode; } @@ -1728,6 +1761,8 @@ _printEnvironment(const EnvironmentInfo *env, FILE *out, bool showPath, const wc } else { fwprintf(out, L" %s\n", argument); } + } else if (env->displayName) { + fwprintf(out, L" %-*s %s\n", TAGWIDTH, argument, env->displayName); } else { fwprintf(out, L" %s\n", argument); } @@ -1890,6 +1925,18 @@ calculateCommandLine(const SearchInfo *search, const EnvironmentInfo *launch, wc } } + if (!exitCode && search->executableArgs) { + if (search->executableArgsLength < 0) { + exitCode = wcscat_s(buffer, bufferLength, search->executableArgs); + } else if (search->executableArgsLength > 0) { + int end = (int)wcsnlen_s(buffer, MAXLEN); + if (end < bufferLength - (search->executableArgsLength + 1)) { + exitCode = wcsncpy_s(&buffer[end], bufferLength - end, + search->executableArgs, search->executableArgsLength); + } + } + } + if (!exitCode && search->restOfCmdLine) { exitCode = wcscat_s(buffer, bufferLength, search->restOfCmdLine); } @@ -2020,13 +2067,15 @@ performSearch(SearchInfo *search, EnvironmentInfo **envs) return exitCode; } - // Check for a shebang line in our script file (or exit quickly if no script - // file was specified) + // Check for a shebang line in our script file + // (or return quickly if no script file was specified) exitCode = checkShebang(search); if (exitCode) { return exitCode; } + // Resolve old-style tags (possibly from a shebang) against py.ini entries + // and environment variables. exitCode = checkDefaults(search); if (exitCode) { return exitCode; @@ -2133,6 +2182,7 @@ process(int argc, wchar_t ** argv) if (isEnvVarSet(L"PYLAUNCHER_DRYRUN")) { debug(L"LaunchCommand: %s\n", launchCommand); debug(L"# Exiting due to PYLAUNCHER_DRYRUN variable\n"); + _setmode(_fileno(stdout), _O_U8TEXT); fwprintf(stdout, L"%s\n", launchCommand); goto abort; } From 8ba4d82e39f4c088e997f47bf24c36d52fa646ed Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Thu, 24 Mar 2022 01:23:25 +0000 Subject: [PATCH 10/13] Add fallback for when IsWow64Process2 is missing --- Doc/using/windows.rst | 2 +- PC/launcher2.c | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Doc/using/windows.rst b/Doc/using/windows.rst index 27a35b3b0823dd..92ac4dc1a05e79 100644 --- a/Doc/using/windows.rst +++ b/Doc/using/windows.rst @@ -957,7 +957,7 @@ Dry Run If an environment variable :envvar:`PYLAUNCHER_DRYRUN` is set (to any value), the launcher will output the command it would have run, but will not actually launch Python. This may be useful for tools that want to use the launcher to -detect and then launch Python directly. Note that the command written to +detect and then launch Python directly. Note that the command written to standard output is always encoded using UTF-8, and may not render correctly in the console. diff --git a/PC/launcher2.c b/PC/launcher2.c index a9afbc852295a1..46f445dd5714d6 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -124,13 +124,33 @@ error(wchar_t * format, ... ) } +typedef BOOL (*PIsWow64Process2)(HANDLE, USHORT*, USHORT*); + + USHORT _getNativeMachine() { static USHORT _nativeMachine = IMAGE_FILE_MACHINE_UNKNOWN; if (_nativeMachine == IMAGE_FILE_MACHINE_UNKNOWN) { USHORT processMachine; - if (!IsWow64Process2(NULL, &processMachine, &_nativeMachine)) { + HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); + PIsWow64Process2 IsWow64Process2 = kernel32 ? + (PIsWow64Process2)GetProcAddress(kernel32, "IsWow64Process2") : + NULL; + if (!IsWow64Process2) { + BOOL wow64Process; + if (!IsWow64Process(NULL, &wow64Process)) { + winerror(0, L"Checking process type"); + } else if (wow64Process) { + // We should always be a 32-bit executable, so if running + // under emulation, it must be a 64-bit host. + _nativeMachine = IMAGE_FILE_MACHINE_AMD64; + } else { + // Not running under emulation, and an old enough OS to not + // have IsWow64Process2, so assume it's x86. + _nativeMachine = IMAGE_FILE_MACHINE_I386; + } + } else if (!IsWow64Process2(NULL, &processMachine, &_nativeMachine)) { winerror(0, L"Checking process type"); } } From 7adf002f427f1ce3cc77c312c8a39422a5216812 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Fri, 25 Mar 2022 15:56:04 +0000 Subject: [PATCH 11/13] Add tests --- Doc/using/windows.rst | 4 ++++ Lib/test/test_launcher.py | 39 +++++++++++++++++++++++++++++++++++---- PC/launcher2.c | 39 ++++++++++++++++++++++++++------------- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/Doc/using/windows.rst b/Doc/using/windows.rst index 92ac4dc1a05e79..0c154103d5a0aa 100644 --- a/Doc/using/windows.rst +++ b/Doc/using/windows.rst @@ -969,6 +969,10 @@ value), and the requested Python version is not installed but is available on the Microsoft Store, the launcher will attempt to install it. This may require user interaction to complete, and you may need to run the command again. +An additional :envvar:`PYLAUNCHER_ALWAYS_INSTALL` variable causes the launcher +to always try to install Python, even if it is detected. This is mainly intended +for testing (and should be used with :envvar:`PYLAUNCHER_DRYRUN`). + Return codes ------------ diff --git a/Lib/test/test_launcher.py b/Lib/test/test_launcher.py index c1d3872de2ad3b..8b83e76ea7446e 100644 --- a/Lib/test/test_launcher.py +++ b/Lib/test/test_launcher.py @@ -157,7 +157,7 @@ def find_py(cls): ) return py_exe - def run_py(self, args, env=None, allow_fail=False): + def run_py(self, args, env=None, allow_fail=False, expect_returncode=0): if not self.py_exe: self.py_exe = self.find_py() @@ -173,17 +173,17 @@ def run_py(self, args, env=None, allow_fail=False): p.wait(10) out = p.stdout.read().decode("utf-8", "replace") err = p.stderr.read().decode("ascii", "replace") - if p.returncode and support.verbose and not allow_fail: + if p.returncode != expect_returncode and support.verbose and not allow_fail: print("++ COMMAND ++") print([self.py_exe, *args]) print("++ STDOUT ++") print(out) print("++ STDERR ++") print(err) - if allow_fail and p.returncode: + if allow_fail and p.returncode != expect_returncode: raise subprocess.CalledProcessError(p.returncode, [self.py_exe, *args], out, err) else: - self.assertEqual(0, p.returncode) + self.assertEqual(expect_returncode, p.returncode) data = { s.partition(":")[0]: s.partition(":")[2].lstrip() for s in err.splitlines() @@ -247,6 +247,31 @@ def test_list_option(self): self.assertEqual(v2, data["SearchInfo.listPaths"]) def test_list(self): + data = self.run_py(["--list"]) + found = {} + expect = {} + for line in data["stdout"].splitlines(): + m = re.match(r"\s*(.+?)\s+(.+)$", line) + if m: + found[m.group(1)] = m.group(2) + for company in TEST_DATA: + company_data = TEST_DATA[company] + tags = [t for t in company_data if isinstance(company_data[t], dict)] + for tag in tags: + arg = f"-V:{company}/{tag}" + expect[arg] = company_data[tag]["DisplayName"] + expect.pop(f"-V:{company}/ignored", None) + + actual = {k: v for k, v in found.items() if k in expect} + try: + self.assertDictEqual(expect, actual) + except: + if support.verbose: + print("*** STDOUT ***") + print(data["stdout"]) + raise + + def test_list_paths(self): data = self.run_py(["--list-paths"]) found = {} expect = {} @@ -383,3 +408,9 @@ def test_py3_shebang(self): self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) self.assertEqual(f"X.Y-arm64.exe -X fake_arg_for_test -prearg {script} -postarg", data["stdout"].strip()) + + def test_install(self): + data = self.run_py(["-V:3.10"], env={"PYLAUNCHER_ALWAYS_INSTALL": "1"}, expect_returncode=111) + cmd = data["stdout"].strip() + self.assertIn("winget.exe", cmd) + self.assertIn("9PJPW5LDXLZ5", cmd) diff --git a/PC/launcher2.c b/PC/launcher2.c index 46f445dd5714d6..beb3a9a43f391f 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -1563,11 +1563,17 @@ _installEnvironment(const wchar_t *command, const wchar_t *arguments) debug(L"# Installing with %s %s\n", command, arguments); if (isEnvVarSet(L"PYLAUNCHER_DRYRUN")) { debug(L"# Exiting due to PYLAUNCHER_DRYRUN\n"); + fflush(stdout); + int mode = _setmode(_fileno(stdout), _O_U8TEXT); if (arguments) { fwprintf_s(stdout, L"\"%s\" %s\n", command, arguments); } else { fwprintf_s(stdout, L"\"%s\"\n", command); } + fflush(stdout); + if (mode >= 0) { + _setmode(_fileno(stdout), mode); + } return RC_INSTALLING; } @@ -1587,10 +1593,11 @@ _installEnvironment(const wchar_t *command, const wchar_t *arguments) return RC_INSTALLING; } -#define WINGET_COMMAND L"Microsoft\\WindowsApps\\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\\winget.exe" -#define WINGET_ARGUMENTS L"install -q %s --exact --accept-package-agreements --source msstore" -#define MSSTORE_COMMAND L"ms-windows-store://pdp/?productid=%s" +const wchar_t *WINGET_COMMAND = L"Microsoft\\WindowsApps\\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\\winget.exe"; +const wchar_t *WINGET_ARGUMENTS = L"install -q %s --exact --accept-package-agreements --source msstore"; + +const wchar_t *MSSTORE_COMMAND = L"ms-windows-store://pdp/?productid=%s"; int installEnvironment(const SearchInfo *search) @@ -2006,6 +2013,20 @@ launchEnvironment(const SearchInfo *search, const EnvironmentInfo *launch, wchar STARTUPINFOW si; PROCESS_INFORMATION pi; + // If this is a dryrun, do not actually launch + if (isEnvVarSet(L"PYLAUNCHER_DRYRUN")) { + debug(L"LaunchCommand: %s\n", launchCommand); + debug(L"# Exiting due to PYLAUNCHER_DRYRUN variable\n"); + fflush(stdout); + int mode = _setmode(_fileno(stdout), _O_U8TEXT); + fwprintf(stdout, L"%s\n", launchCommand); + fflush(stdout); + if (mode >= 0) { + _setmode(_fileno(stdout), mode); + } + return 0; + } + #if defined(_WINDOWS) /* When explorer launches a Windows (GUI) application, it displays @@ -2165,7 +2186,8 @@ process(int argc, wchar_t ** argv) if (search.executablePath == NULL) { exitCode = selectEnvironment(&search, envs, &env); // If none found, and if permitted, install it - if (exitCode == RC_NO_PYTHON && isEnvVarSet(L"PYLAUNCHER_ALLOW_INSTALL")) { + if (exitCode == RC_NO_PYTHON && isEnvVarSet(L"PYLAUNCHER_ALLOW_INSTALL") || + isEnvVarSet(L"PYLAUNCHER_ALWAYS_INSTALL")) { exitCode = installEnvironment(&search); if (!exitCode) { // Successful install, so we need to re-scan and select again @@ -2198,15 +2220,6 @@ process(int argc, wchar_t ** argv) goto abort; } - // For testing purposes, do not actually launch - if (isEnvVarSet(L"PYLAUNCHER_DRYRUN")) { - debug(L"LaunchCommand: %s\n", launchCommand); - debug(L"# Exiting due to PYLAUNCHER_DRYRUN variable\n"); - _setmode(_fileno(stdout), _O_U8TEXT); - fwprintf(stdout, L"%s\n", launchCommand); - goto abort; - } - // Launch selected runtime exitCode = launchEnvironment(&search, env, launchCommand); From 4e38e3f2981d1f910b6ffe893c23286aaf745169 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Fri, 25 Mar 2022 16:52:25 +0000 Subject: [PATCH 12/13] Handle 'no installs at all' and pyshellext --- PC/launcher2.c | 22 ++++++++++++++++++--- Tools/msi/launcher/launcher.wixproj | 28 ++++++++++++++++++--------- Tools/msi/launcher/launcher_files.wxs | 9 +++++++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/PC/launcher2.c b/PC/launcher2.c index beb3a9a43f391f..873538171d93e3 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -35,6 +35,7 @@ #define RC_INTERNAL_ERROR 109 #define RC_DUPLICATE_ITEM 110 #define RC_INSTALLING 111 +#define RC_NO_PYTHON_AT_ALL 112 static FILE * log_fp = NULL; @@ -1750,7 +1751,7 @@ selectEnvironment(const SearchInfo *search, EnvironmentInfo *root, EnvironmentIn } if (!root) { *best = NULL; - return 0; + return RC_NO_PYTHON_AT_ALL; } if (!root->next && !root->prev) { *best = root; @@ -1837,6 +1838,7 @@ listEnvironments(EnvironmentInfo *env, FILE * out, bool showPath) bool isDefault = true; if (!env) { + fwprintf_s(stdout, L"No installed Pythons found!\n"); return 0; } @@ -1875,7 +1877,13 @@ listEnvironments(EnvironmentInfo *env, FILE * out, bool showPath) } */ - return _listAllEnvironments(env, out, showPath, &isDefault); + int mode = _setmode(_fileno(out), _O_U8TEXT); + int exitCode = _listAllEnvironments(env, out, showPath, &isDefault); + fflush(out); + if (mode >= 0) { + _setmode(_fileno(out), mode); + } + return exitCode; } @@ -2208,11 +2216,19 @@ process(int argc, wchar_t ** argv) } goto abort; } + if (exitCode == RC_NO_PYTHON_AT_ALL) { + fputws(L"No installed Python found!\n", stderr); + goto abort; + } if (exitCode) { goto abort; } - debug(L"env.company: %s\nenv.tag: %s\n", env->company, env->tag); + if (env) { + debug(L"env.company: %s\nenv.tag: %s\n", env->company, env->tag); + } else { + debug(L"env.company: (null)\nenv.tag: (null)\n"); + } } exitCode = calculateCommandLine(&search, env, launchCommand, sizeof(launchCommand) / sizeof(launchCommand[0])); diff --git a/Tools/msi/launcher/launcher.wixproj b/Tools/msi/launcher/launcher.wixproj index 7ff169029e4fe5..de770bdd3006c2 100644 --- a/Tools/msi/launcher/launcher.wixproj +++ b/Tools/msi/launcher/launcher.wixproj @@ -8,6 +8,7 @@ UpgradeCode=1B68A0EC-4DD3-5134-840E-73854B0863F1;SuppressUpgradeTable=1;$(DefineConstants) true ICE80 + <_Rebuild>Build @@ -18,18 +19,27 @@ - - - + + + + <_Rebuild>Rebuild + + + + + + + + - - + + - - + + - - + + diff --git a/Tools/msi/launcher/launcher_files.wxs b/Tools/msi/launcher/launcher_files.wxs index 5b79d76bdfe673..2c6c808137a6ff 100644 --- a/Tools/msi/launcher/launcher_files.wxs +++ b/Tools/msi/launcher/launcher_files.wxs @@ -33,6 +33,15 @@ + From 8865fdce15a7f48c2547e73478a56c4f6e707402 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Mon, 28 Mar 2022 19:49:33 +0100 Subject: [PATCH 13/13] Add fallback to test --- Lib/test/test_launcher.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_launcher.py b/Lib/test/test_launcher.py index 8b83e76ea7446e..2fb5aae628a672 100644 --- a/Lib/test/test_launcher.py +++ b/Lib/test/test_launcher.py @@ -412,5 +412,12 @@ def test_py3_shebang(self): def test_install(self): data = self.run_py(["-V:3.10"], env={"PYLAUNCHER_ALWAYS_INSTALL": "1"}, expect_returncode=111) cmd = data["stdout"].strip() - self.assertIn("winget.exe", cmd) + # If winget is runnable, we should find it. Otherwise, we'll be trying + # to open the Store. + try: + subprocess.check_call(["winget.exe", "--version"]) + except FileNotFoundError: + self.assertIn("ms-windows-store://", cmd) + else: + self.assertIn("winget.exe", cmd) self.assertIn("9PJPW5LDXLZ5", cmd)