From eda60916bc88f8af736790ffd52381e8bb83ae83 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 1 Feb 2023 21:06:56 +0000 Subject: [PATCH] gh-101467: Correct py.exe handling of prefix matches and cases when only one runtime is installed (GH-101468) --- Doc/using/windows.rst | 35 ++++++++-- Lib/test/test_launcher.py | 29 +++++++-- ...-01-31-16-50-07.gh-issue-101467.ye9t-L.rst | 3 + PC/launcher2.c | 65 ++++++++++++++++--- 4 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 Misc/NEWS.d/next/Windows/2023-01-31-16-50-07.gh-issue-101467.ye9t-L.rst diff --git a/Doc/using/windows.rst b/Doc/using/windows.rst index bd09666d5e0..1c4e41c0e0e 100644 --- a/Doc/using/windows.rst +++ b/Doc/using/windows.rst @@ -743,22 +743,47 @@ command:: py -2 -You should find the latest version of Python 3.x starts. - If you see the following error, you do not have the launcher installed:: 'py' is not recognized as an internal or external command, operable program or batch file. -Per-user installations of Python do not add the launcher to :envvar:`PATH` -unless the option was selected on installation. - The command:: py --list displays the currently installed version(s) of Python. +The ``-x.y`` argument is the short form of the ``-V:Company/Tag`` argument, +which allows selecting a specific Python runtime, including those that may have +come from somewhere other than python.org. Any runtime registered by following +:pep:`514` will be discoverable. The ``--list`` command lists all available +runtimes using the ``-V:`` format. + +When using the ``-V:`` argument, specifying the Company will limit selection to +runtimes from that provider, while specifying only the Tag will select from all +providers. Note that omitting the slash implies a tag:: + + # Select any '3.*' tagged runtime + py -V:3 + + # Select any 'PythonCore' released runtime + py -V:PythonCore/ + + # Select PythonCore's latest Python 3 runtime + py -V:PythonCore/3 + +The short form of the argument (``-3``) only ever selects from core Python +releases, and not other distributions. However, the longer form (``-V:3``) will +select from any. + +The Company is matched on the full string, case-insenitive. The Tag is matched +oneither the full string, or a prefix, provided the next character is a dot or a +hyphen. This allows ``-V:3.1`` to match ``3.1-32``, but not ``3.10``. Tags are +sorted using numerical ordering (``3.10`` is newer than ``3.1``), but are +compared using text (``-V:3.01`` does not match ``3.1``). + + Virtual environments ^^^^^^^^^^^^^^^^^^^^ diff --git a/Lib/test/test_launcher.py b/Lib/test/test_launcher.py index 351a638c1dd..2f35eaf08a2 100644 --- a/Lib/test/test_launcher.py +++ b/Lib/test/test_launcher.py @@ -56,7 +56,17 @@ TEST_DATA = { None: sys.prefix, } }, - } + }, + "PythonTestSuite1": { + "DisplayName": "Python Test Suite Single", + "3.100": { + "DisplayName": "Single Interpreter", + "InstallPath": { + None: sys.prefix, + "ExecutablePath": sys.executable, + } + } + }, } @@ -206,6 +216,7 @@ class RunPyMixin: **{k.upper(): v for k, v in os.environ.items() if k.upper() not in ignore}, "PYLAUNCHER_DEBUG": "1", "PYLAUNCHER_DRYRUN": "1", + "PYLAUNCHER_LIMIT_TO_COMPANY": "", **{k.upper(): v for k, v in (env or {}).items()}, } if not argv: @@ -388,23 +399,33 @@ class TestLauncher(unittest.TestCase, RunPyMixin): self.assertEqual(company, data["env.company"]) self.assertEqual("3.100", data["env.tag"]) - data = self.run_py([f"-V:3.100-3"]) + data = self.run_py([f"-V:3.100-32"]) 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"]) + data = self.run_py([f"-V:3.100-arm64"]) 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"]) + data = self.run_py([f"-V:{company}/3.1"], expect_returncode=103) + + data = self.run_py([f"-V:{company}/3.100"]) self.assertEqual("X.Y.exe", data["LaunchCommand"]) self.assertEqual(company, data["env.company"]) self.assertEqual("3.100", data["env.tag"]) + def test_filter_with_single_install(self): + company = "PythonTestSuite1" + data = self.run_py( + [f"-V:Nonexistent"], + env={"PYLAUNCHER_LIMIT_TO_COMPANY": company}, + expect_returncode=103, + ) + def test_search_major_3(self): try: data = self.run_py(["-3"], allow_fail=True) diff --git a/Misc/NEWS.d/next/Windows/2023-01-31-16-50-07.gh-issue-101467.ye9t-L.rst b/Misc/NEWS.d/next/Windows/2023-01-31-16-50-07.gh-issue-101467.ye9t-L.rst new file mode 100644 index 00000000000..4d4da05afa2 --- /dev/null +++ b/Misc/NEWS.d/next/Windows/2023-01-31-16-50-07.gh-issue-101467.ye9t-L.rst @@ -0,0 +1,3 @@ +The ``py.exe`` launcher now correctly filters when only a single runtime is +installed. It also correctly handles prefix matches on tags so that ``-3.1`` +does not match ``3.11``, but would still match ``3.1-32``. diff --git a/PC/launcher2.c b/PC/launcher2.c index 2052a2ffeb5..beeb2ae46b8 100644 --- a/PC/launcher2.c +++ b/PC/launcher2.c @@ -295,6 +295,30 @@ _startsWithArgument(const wchar_t *x, int xLen, const wchar_t *y, int yLen) } +// Unlike regular startsWith, this function requires that the following +// character is either NULL (that is, the entire string matches) or is one of +// the characters in 'separators'. +bool +_startsWithSeparated(const wchar_t *x, int xLen, const wchar_t *y, int yLen, const wchar_t *separators) +{ + if (!x || !y) { + return false; + } + yLen = yLen < 0 ? (int)wcsnlen_s(y, MAXLEN) : yLen; + xLen = xLen < 0 ? (int)wcsnlen_s(x, MAXLEN) : xLen; + if (xLen < yLen) { + return false; + } + if (xLen == yLen) { + return 0 == _compare(x, xLen, y, yLen); + } + return separators && + 0 == _compare(x, yLen, y, yLen) && + wcschr(separators, x[yLen]) != NULL; +} + + + /******************************************************************************\ *** HELP TEXT *** \******************************************************************************/ @@ -409,6 +433,9 @@ typedef struct { bool listPaths; // if true, display help message before contiuning bool help; + // if set, limits search to registry keys with the specified Company + // This is intended for debugging and testing only + const wchar_t *limitToCompany; // dynamically allocated buffers to free later struct _SearchInfoBuffer *_buffer; } SearchInfo; @@ -489,6 +516,7 @@ dumpSearchInfo(SearchInfo *search) DEBUG_BOOL(list); DEBUG_BOOL(listPaths); DEBUG_BOOL(help); + DEBUG(limitToCompany); #undef DEBUG_BOOL #undef DEBUG_2 #undef DEBUG @@ -1606,6 +1634,10 @@ registrySearch(const SearchInfo *search, EnvironmentInfo **result, HKEY root, in } break; } + if (search->limitToCompany && 0 != _compare(search->limitToCompany, -1, buffer, cchBuffer)) { + debug(L"# Skipping %s due to PYLAUNCHER_LIMIT_TO_COMPANY\n", buffer); + continue; + } HKEY subkey; if (ERROR_SUCCESS == RegOpenKeyExW(root, buffer, 0, KEY_READ, &subkey)) { exitCode = _registrySearchTags(search, result, subkey, sortKey, buffer, fallbackArch); @@ -1884,6 +1916,11 @@ collectEnvironments(const SearchInfo *search, EnvironmentInfo **result) } } + if (search->limitToCompany) { + debug(L"# Skipping APPX search due to PYLAUNCHER_LIMIT_TO_COMPANY\n"); + return 0; + } + 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) { @@ -2053,12 +2090,15 @@ _companyMatches(const SearchInfo *search, const EnvironmentInfo *env) bool -_tagMatches(const SearchInfo *search, const EnvironmentInfo *env) +_tagMatches(const SearchInfo *search, const EnvironmentInfo *env, int searchTagLength) { - if (!search->tag || !search->tagLength) { + if (searchTagLength < 0) { + searchTagLength = search->tagLength; + } + if (!search->tag || !searchTagLength) { return true; } - return _startsWith(env->tag, -1, search->tag, search->tagLength); + return _startsWithSeparated(env->tag, -1, search->tag, searchTagLength, L".-"); } @@ -2095,7 +2135,7 @@ _selectEnvironment(const SearchInfo *search, EnvironmentInfo *env, EnvironmentIn } if (!search->oldStyleTag) { - if (_companyMatches(search, env) && _tagMatches(search, env)) { + if (_companyMatches(search, env) && _tagMatches(search, env, -1)) { // 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" @@ -2120,7 +2160,7 @@ _selectEnvironment(const SearchInfo *search, EnvironmentInfo *env, EnvironmentIn } } - if (_startsWith(env->tag, -1, search->tag, tagLength)) { + if (_tagMatches(search, env, tagLength)) { if (exclude32Bit && _is32Bit(env)) { debug(L"# Excluding %s/%s because it looks like 32bit\n", env->company, env->tag); } else if (only32Bit && !_is32Bit(env)) { @@ -2147,10 +2187,6 @@ selectEnvironment(const SearchInfo *search, EnvironmentInfo *root, EnvironmentIn *best = NULL; return RC_NO_PYTHON_AT_ALL; } - if (!root->next && !root->prev) { - *best = root; - return 0; - } EnvironmentInfo *result = NULL; int exitCode = _selectEnvironment(search, root, &result); @@ -2560,6 +2596,17 @@ process(int argc, wchar_t ** argv) debug(L"argv0: %s\nversion: %S\n", argv[0], PY_VERSION); } + DWORD len = GetEnvironmentVariableW(L"PYLAUNCHER_LIMIT_TO_COMPANY", NULL, 0); + if (len > 1) { + wchar_t *limitToCompany = allocSearchInfoBuffer(&search, len); + search.limitToCompany = limitToCompany; + if (0 == GetEnvironmentVariableW(L"PYLAUNCHER_LIMIT_TO_COMPANY", limitToCompany, len)) { + exitCode = RC_INTERNAL_ERROR; + winerror(0, L"Failed to read PYLAUNCHER_LIMIT_TO_COMPANY variable"); + goto abort; + } + } + search.originalCmdLine = GetCommandLineW(); exitCode = performSearch(&search, &envs);