Skip to content
Merged
3 changes: 3 additions & 0 deletions _msbuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ class ResourceFile(CSourceFile):
CFunction('date_as_str'),
CFunction('datetime_as_str'),
CFunction('reg_rename_key'),
CFunction('get_current_package'),
CFunction('read_alias_package'),
CFunction('broadcast_settings_change'),
source='src/_native',
RootNamespace='_native',
)
Expand Down
3 changes: 3 additions & 0 deletions _msbuild_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
CFunction('date_as_str'),
CFunction('datetime_as_str'),
CFunction('reg_rename_key'),
CFunction('get_current_package'),
CFunction('read_alias_package'),
CFunction('broadcast_settings_change'),
source='src/_native',
),
DllPackage('_shellext_test',
Expand Down
114 changes: 114 additions & 0 deletions src/_native/misc.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include <Python.h>
#include <windows.h>
#include <appmodel.h>

#include "helpers.h"

Expand Down Expand Up @@ -119,4 +120,117 @@ PyObject *reg_rename_key(PyObject *, PyObject *args, PyObject *kwargs) {
return r;
}


PyObject *get_current_package(PyObject *, PyObject *, PyObject *) {
wchar_t package_name[256];
UINT32 cch = sizeof(package_name) / sizeof(package_name[0]);
int err = GetCurrentPackageFamilyName(&cch, package_name);
switch (err) {
case ERROR_SUCCESS:
return PyUnicode_FromWideChar(package_name, cch);
case APPMODEL_ERROR_NO_PACKAGE:
return Py_GetConstant(Py_CONSTANT_NONE);
default:
PyErr_SetFromWindowsErr(err);
return NULL;
}
}


PyObject *read_alias_package(PyObject *, PyObject *args, PyObject *kwargs) {
static const char * keywords[] = {"path", NULL};
wchar_t *path = NULL;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&:read_alias_package", keywords,
as_utf16, &path)) {
return NULL;
}

HANDLE h = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL);
PyMem_Free(path);
if (h == INVALID_HANDLE_VALUE) {
PyErr_SetFromWindowsErr(0);
return NULL;
}

struct {
DWORD tag;
DWORD _reserved1;
DWORD _reserved2;
wchar_t package_name[256];
wchar_t nul;
} buffer;
DWORD nread;

if (!DeviceIoControl(h, FSCTL_GET_REPARSE_POINT, NULL, 0,
&buffer, sizeof(buffer), &nread, NULL)
// we expect our buffer to be too small, but we only want the package
&& GetLastError() != ERROR_MORE_DATA) {
PyErr_SetFromWindowsErr(0);
CloseHandle(h);
return NULL;
}

CloseHandle(h);

if (buffer.tag != IO_REPARSE_TAG_APPEXECLINK) {
return Py_GetConstant(Py_CONSTANT_NONE);
}

buffer.nul = 0;
return PyUnicode_FromWideChar(buffer.package_name, -1);
}


typedef LRESULT (*PSendMessageTimeoutW)(
HWND hWnd,
UINT Msg,
WPARAM wParam,
LPARAM lParam,
UINT fuFlags,
UINT uTimeout,
PDWORD_PTR lpdwResult
);

PyObject *broadcast_settings_change(PyObject *, PyObject *, PyObject *) {
// Avoid depending on user32 because it's so slow
HMODULE user32 = LoadLibraryExW(L"user32.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32);
if (!user32) {
PyErr_SetFromWindowsErr(0);
return NULL;
}
PSendMessageTimeoutW sm = (PSendMessageTimeoutW)GetProcAddress(user32, "SendMessageTimeoutW");
if (!sm) {
PyErr_SetFromWindowsErr(0);
FreeLibrary(user32);
return NULL;
}

// SendMessageTimeout needs special error handling
SetLastError(0);
LPARAM lParam = (LPARAM)L"Environment";

if (!(*sm)(
HWND_BROADCAST,
WM_SETTINGCHANGE,
NULL,
lParam,
SMTO_ABORTIFHUNG,
50,
NULL
)) {
int err = GetLastError();
if (!err) {
PyErr_SetString(PyExc_OSError, "Unspecified error");
} else {
PyErr_SetFromWindowsErr(err);
}
FreeLibrary(user32);
return NULL;
}

FreeLibrary(user32);
return Py_GetConstant(Py_CONSTANT_NONE);
}

}
68 changes: 60 additions & 8 deletions src/manage/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@
DEFAULT_TAG = "3.14"


# TODO: Remove the /dev/ for stable release
HELP_URL = "https://docs.python.org/dev/using/windows"


COPYRIGHT = f"""Python installation manager {__version__}
Copyright (c) Python Software Foundation. All Rights Reserved.
"""


if EXE_NAME.casefold() == "py-manager".casefold():
EXE_NAME = "py"

Check warning on line 38 in src/manage/commands.py

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L38

Added line #L38 was not covered by tests


WELCOME = f"""!B!Python install manager was successfully updated to {__version__}.!W!
"""

Expand Down Expand Up @@ -188,6 +196,7 @@
"enable-shortcut-kinds": ("enable_shortcut_kinds", _NEXT, config_split),
"disable-shortcut-kinds": ("disable_shortcut_kinds", _NEXT, config_split),
"help": ("show_help", True), # nested to avoid conflict with command
"configure": ("configure", True),
# Set when the manager is doing an automatic install.
# Generally won't be set by manual invocation
"automatic": ("automatic", True),
Expand All @@ -202,6 +211,10 @@
"force": ("confirm", False),
"help": ("show_help", True), # nested to avoid conflict with command
},

"**first_run": {
"explicit": ("explicit", True),
},
}


Expand Down Expand Up @@ -240,6 +253,17 @@
"disable_shortcut_kinds": (str, config_split_append),
},

"first_run": {
"enabled": (config_bool, None, "env"),
"explicit": (config_bool, None),
"check_app_alias": (config_bool, None, "env"),
"check_long_paths": (config_bool, None, "env"),
"check_py_on_path": (config_bool, None, "env"),
"check_any_install": (config_bool, None, "env"),
"check_default_tag": (config_bool, None, "env"),
Copy link

Copilot AI May 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'check_default_tag' option is declared in the CLI schema but never used in the first_run handler; remove it or implement its behavior to keep the configuration schema in sync with the code.

Suggested change
"check_default_tag": (config_bool, None, "env"),
# Removed the unused "check_default_tag" option to keep the schema in sync with the code.

Copilot uses AI. Check for mistakes.
"check_global_dir": (config_bool, None, "env"),
},

# These configuration settings are intended for administrative override only
# For example, if you are managing deployments that will use your own index
# and/or your own builds.
Expand Down Expand Up @@ -419,11 +443,11 @@
# If our command has any config, load them to override anything that
# wasn't set on the command line.
try:
cmd_config = config[self.CMD]
cmd_config = config[self.CMD.lstrip("*")]
except (AttributeError, LookupError):
pass
else:
arg_names = frozenset(CONFIG_SCHEMA[self.CMD])
arg_names = frozenset(CONFIG_SCHEMA[self.CMD.lstrip("*")])

Check warning on line 450 in src/manage/commands.py

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L450

Added line #L450 was not covered by tests
for k, v in cmd_config.items():
if k in arg_names and k not in _set_args:
LOGGER.debug("Overriding command option %s with %r", k, v)
Expand Down Expand Up @@ -511,7 +535,7 @@
logs_dir = Path(os.getenv("TMP") or os.getenv("TEMP") or os.getcwd())
from _native import datetime_as_str
self._log_file = logs_dir / "python_{}_{}_{}.log".format(
self.CMD, datetime_as_str(), os.getpid()
self.CMD.strip("*"), datetime_as_str(), os.getpid()
)
return self._log_file

Expand Down Expand Up @@ -564,8 +588,7 @@
LOGGER.print(r)

LOGGER.print()
# TODO: Remove the /dev/ for stable release
LOGGER.print("Find additional information at !B!https://docs.python.org/dev/using/windows!W!.")
LOGGER.print("Find additional information at !B!%s!W!.", HELP_URL)
LOGGER.print()

@classmethod
Expand Down Expand Up @@ -746,6 +769,7 @@
-u, --update Overwrite existing install if a newer version is available.
--dry-run Choose runtime but do not install
--refresh Update shortcuts and aliases for all installed versions.
--configure Re-run the system configuration helper.
--by-id Require TAG to exactly match the install ID. (For advanced use.)
!B!<TAG> <TAG>!W! ... One or more tags to install (Company\Tag format)

Expand Down Expand Up @@ -775,6 +799,7 @@
dry_run = False
refresh = False
by_id = False
configure = False
automatic = False
from_script = None
enable_shortcut_kinds = None
Expand All @@ -801,9 +826,13 @@
self.download = Path(self.download).absolute()

def execute(self):
from .install_command import execute
self.show_welcome()
execute(self)
if self.configure:
cmd = FirstRun(["**first_run", "--explicit"], self.root)
cmd.execute()

Check warning on line 832 in src/manage/commands.py

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L831-L832

Added lines #L831 - L832 were not covered by tests
else:
from .install_command import execute
execute(self)

Check warning on line 835 in src/manage/commands.py

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L834-L835

Added lines #L834 - L835 were not covered by tests


class UninstallCommand(BaseCommand):
Expand Down Expand Up @@ -891,7 +920,7 @@


class HelpWithErrorCommand(HelpCommand):
CMD = "__help_with_error"
CMD = "**help_with_error"

def __init__(self, args, root=None):
# Essentially disable argument processing for this command
Expand Down Expand Up @@ -945,6 +974,29 @@
super().__init__([], root)


class FirstRun(BaseCommand):
CMD = "**first_run"
enabled = True
explicit = False
check_app_alias = True
check_long_paths = True
check_py_on_path = True
check_any_install = True
check_default_tag = True
check_global_dir = True

def execute(self):
if not self.enabled:
return
from .firstrun import first_run
first_run(self)

Check warning on line 992 in src/manage/commands.py

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L990-L992

Added lines #L990 - L992 were not covered by tests
if not self.explicit:
show_help([])

Check warning on line 994 in src/manage/commands.py

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L994

Added line #L994 was not covered by tests
if self.confirm and not self.ask_ny(f"View more help online? (!B!{HELP_URL}!W!)"):
import os
os.startfile(HELP_URL)

Check warning on line 997 in src/manage/commands.py

View check run for this annotation

Codecov / codecov/patch

src/manage/commands.py#L996-L997

Added lines #L996 - L997 were not covered by tests


def load_default_config(root):
return DefaultConfig(root)

Expand Down
Loading