Skip to content

[3.11] gh-101467: Correct py.exe handling of prefix matches and cases when only one runtime is installed (GH-101468) #101504

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions Doc/using/windows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -730,22 +730,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
^^^^^^^^^^^^^^^^^^^^

Expand Down
29 changes: 25 additions & 4 deletions Lib/test/test_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,17 @@
None: sys.prefix,
}
},
}
},
"PythonTestSuite1": {
"DisplayName": "Python Test Suite Single",
"3.100": {
"DisplayName": "Single Interpreter",
"InstallPath": {
None: sys.prefix,
"ExecutablePath": sys.executable,
}
}
},
}


Expand Down Expand Up @@ -207,6 +217,7 @@ def run_py(self, args, env=None, allow_fail=False, expect_returncode=0, argv=Non
**{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:
Expand Down Expand Up @@ -389,23 +400,33 @@ def test_filter_to_tag(self):
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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``.
65 changes: 56 additions & 9 deletions PC/launcher2.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 ***
\******************************************************************************/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -485,6 +512,7 @@ dumpSearchInfo(SearchInfo *search)
DEBUG_BOOL(list);
DEBUG_BOOL(listPaths);
DEBUG_BOOL(help);
DEBUG(limitToCompany);
#undef DEBUG_BOOL
#undef DEBUG_2
#undef DEBUG
Expand Down Expand Up @@ -1602,6 +1630,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);
Expand Down Expand Up @@ -1880,6 +1912,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) {
Expand Down Expand Up @@ -2049,12 +2086,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".-");
}


Expand Down Expand Up @@ -2091,7 +2131,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"
Expand All @@ -2116,7 +2156,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)) {
Expand All @@ -2143,10 +2183,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);
Expand Down Expand Up @@ -2556,6 +2592,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);
Expand Down