From fc64f28bdfad7e1bf0f32048dec5554fc84ddd9c Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Thu, 20 Nov 2025 23:28:58 +0000 Subject: [PATCH 01/27] [WIP] Start implementation of entrypoint support --- src/manage/entrypointutils.py | 33 ++++++++++++ src/manage/install_command.py | 95 ++++++++++++++++++++++------------- 2 files changed, 92 insertions(+), 36 deletions(-) create mode 100644 src/manage/entrypointutils.py diff --git a/src/manage/entrypointutils.py b/src/manage/entrypointutils.py new file mode 100644 index 0000000..289d3c7 --- /dev/null +++ b/src/manage/entrypointutils.py @@ -0,0 +1,33 @@ + +def _scan(prefix, dirs): + for dirname in dirs or (): + # TODO: Handle invalid entries + d = install["prefix"] / dirname + + # TODO: Scan d for dist-info directories with entry_points.txt + # Filter down to [console_scripts] and [gui_scripts] + + # TODO: Yield the alias name and script contents + # import sys; from import ; sys.exit(()) + + +def scan_and_create(cmd, install, shortcut): + for name, code in _scan(install["prefix"], shortcut.get("dirs")): + # TODO: Store name in cmd's metadata. + # If it's already been stored, skip all further processing. + + # TOOD: Copy the launcher template and create a standard __target__ file + # Also create an -script.py file containing code + # pymanager/launcher.cpp wil need to be updated to use this script. + # Regular alias creation will need to delete these scripts. + + +def cleanup(cmd, install_shortcut_pairs): + seen_names = set() + for install, shortcut in install_shortcut_pairs: + for name, code in _scan(install["prefix"], shortcut.get("dirs")): + seen_names.add(name) + + # TODO: Scan existing aliases, filter to those with -script.py files + + # TODO: Excluding any in seen_names, delete unused aliases diff --git a/src/manage/install_command.py b/src/manage/install_command.py index ac67e61..a5a5f43 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -25,6 +25,8 @@ DOWNLOAD_CACHE = {} +DEFAULT_SITE_DIRS = ["Lib\\site-packages", "Scripts"] + def _multihash(file, hashes): import hashlib LOGGER.debug("Calculating hashes: %s", ", ".join(hashes)) @@ -346,10 +348,21 @@ def _cleanup_arp_entries(cmd, install_shortcut_pairs): cleanup([i for i, s in install_shortcut_pairs], cmd.tags) +def _create_entrypoints(cmd, install, shortcut): + from .entrypointutils import scan_and_create + scan_and_create(cmd, install, shortcut) + + +def _cleanup_entrypoints(cmd, install_shortcut_pairs): + from .entrypointutils import cleanup + cleanup(cmd, install_shortcut_pairs) + + SHORTCUT_HANDLERS = { "pep514": (_create_shortcut_pep514, _cleanup_shortcut_pep514), "start": (_create_start_shortcut, _cleanup_start_shortcut), "uninstall": (_create_arp_entry, _cleanup_arp_entries), + "site-dirs": (_create_entrypoints, _cleanup_entrypoints), } @@ -396,6 +409,16 @@ def update_all_shortcuts(cmd): create(cmd, i, s) shortcut_written.setdefault(s["kind"], []).append((i, s)) + # Earlier releases may not have site_dirs. If not, assume + if ("site-dirs" in (cmd.enable_shortcut_kinds or ("site-dirs",)) and + "site-dirs" not in (cmd.disable_shortcut_kinds or ()) and + all(s["kind"] != "site-dirs" for s in i.get("shortcuts", ()))): + + create, cleanup = SHORTCUT_HANDLERS["site-dirs"] + s = dict(kind="site-dirs", dirs=DEFAULT_SITE_DIRS) + create(cmd, i, s) + shortcut_written.setdefault("site-dirs", []).append((i, s)) + if cmd.global_dir and cmd.global_dir.is_dir() and cmd.launcher_exe: for target in cmd.global_dir.glob("*.exe.__target__"): alias = target.with_suffix("") @@ -522,15 +545,7 @@ def _download_one(cmd, source, install, download_dir, *, must_copy=False): return package -def _should_preserve_on_upgrade(cmd, root, path): - if path.match("site-packages"): - return True - if path.parent == root and path.match("Scripts"): - return True - return False - - -def _preserve_site(cmd, root): +def _preserve_site(cmd, root, install): if not root.is_dir(): return None if not cmd.preserve_site_on_upgrade: @@ -542,39 +557,47 @@ def _preserve_site(cmd, root): if cmd.repair: LOGGER.verbose("Not preserving site directory because of --repair") return None + state = [] i = 0 - dirs = [root] + + site_dirs = DEFAULT_SITE_DIRS + for s in install.get("shortcuts", ()): + if s["kind"] == "site-dirs": + site_dirs = s.get("dirs", ()) + break + target_root = root.with_name(f"_{root.name}") target_root.mkdir(parents=True, exist_ok=True) - while dirs: - if _should_preserve_on_upgrade(cmd, root, dirs[0]): - while True: - target = target_root / str(i) - i += 1 - try: - unlink(target) - break - except FileNotFoundError: - break - except OSError: - LOGGER.verbose("Failed to remove %s.", target) - try: - LOGGER.info("Preserving %s during update.", dirs[0].relative_to(root)) - except ValueError: - # Just in case a directory goes weird, so we don't break - LOGGER.verbose(exc_info=True) - LOGGER.verbose("Moving %s to %s", dirs[0], target) + + for dirname in site_dirs: + d = root / dirname + if not d.is_dir(): + continue + + while True: + target = target_root / str(i) + i += 1 try: - dirs[0].rename(target) + unlink(target) + break + except FileNotFoundError: + break except OSError: - LOGGER.warn("Failed to preserve %s during update.", dirs[0]) - LOGGER.verbose("TRACEBACK", exc_info=True) - else: - state.append((dirs[0], target)) + LOGGER.verbose("Failed to remove %s.", target) + try: + LOGGER.info("Preserving %s during update.", d.relative_to(root)) + except ValueError: + # Just in case a directory goes weird, so we don't break + LOGGER.verbose(exc_info=True) + LOGGER.verbose("Moving %s to %s", d, target) + try: + d.rename(target) + except OSError: + LOGGER.warn("Failed to preserve %s during update.", d) + LOGGER.verbose("TRACEBACK", exc_info=True) else: - dirs.extend(d for d in dirs[0].iterdir() if d.is_dir()) - dirs.pop(0) + state.append((d, target)) # Append None, target_root last to clean up after restore is done state.append((None, target_root)) return state @@ -634,7 +657,7 @@ def _install_one(cmd, source, install, *, target=None): dest = target or (cmd.install_dir / install["id"]) - preserved_site = _preserve_site(cmd, dest) + preserved_site = _preserve_site(cmd, dest, install) LOGGER.verbose("Extracting %s to %s", package, dest) if not cmd.repair: From 8b83213d37cc2fe47f3cf3f4546edef10e2482e0 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Mon, 24 Nov 2025 21:52:26 +0000 Subject: [PATCH 02/27] [WIP] Updated, implemented, and existing tests pass --- src/manage/aliasutils.py | 234 +++++++++++++++++++++++++ src/manage/entrypointutils.py | 33 ---- src/manage/install_command.py | 118 ++----------- src/pymanager/launcher.cpp | 62 +++++++ tests/test_alias.py | 218 +++++++++++++++++++++++ tests/test_install_command.py | 313 ++++++---------------------------- 6 files changed, 580 insertions(+), 398 deletions(-) create mode 100644 src/manage/aliasutils.py delete mode 100644 src/manage/entrypointutils.py create mode 100644 tests/test_alias.py diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py new file mode 100644 index 0000000..c3aa734 --- /dev/null +++ b/src/manage/aliasutils.py @@ -0,0 +1,234 @@ +import os + +from .fsutils import ensure_tree, unlink +from .logging import LOGGER +from .pathutils import Path +from .tagutils import install_matches_any + + +def _if_exists(launcher, plat): + suffix = "." + launcher.suffix.lstrip(".") + plat_launcher = launcher.parent / f"{launcher.stem}{plat}{suffix}" + if plat_launcher.is_file(): + return plat_launcher + return launcher + + +def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link): + p = (cmd.global_dir / alias["name"]) + target = Path(target) + ensure_tree(p) + launcher = cmd.launcher_exe + if alias.get("windowed"): + launcher = cmd.launcherw_exe or launcher + + plat = install["tag"].rpartition("-")[-1] + if plat: + LOGGER.debug("Checking for launcher for platform -%s", plat) + launcher = _if_exists(launcher, f"-{plat}") + if not launcher.is_file(): + LOGGER.debug("Checking for launcher for default platform %s", cmd.default_platform) + launcher = _if_exists(launcher, cmd.default_platform) + if not launcher.is_file(): + LOGGER.debug("Checking for launcher for -64") + launcher = _if_exists(launcher, "-64") + LOGGER.debug("Create %s linking to %s using %s", alias["name"], target, launcher) + if not launcher or not launcher.is_file(): + if install_matches_any(install, getattr(cmd, "tags", None)): + LOGGER.warn("Skipping %s alias because the launcher template was not found.", alias["name"]) + else: + LOGGER.debug("Skipping %s alias because the launcher template was not found.", alias["name"]) + return + + try: + launcher_bytes = launcher.read_bytes() + except OSError: + warnings_shown = cmd.scratch.setdefault("aliasutils.create_alias.warnings_shown", set()) + if str(launcher) not in warnings_shown: + LOGGER.warn("Failed to read launcher template at %s.", launcher) + warnings_shown.add(str(launcher)) + LOGGER.debug("Failed to read %s", launcher, exc_info=True) + return + + existing_bytes = b'' + try: + with open(p, 'rb') as f: + existing_bytes = f.read(len(launcher_bytes) + 1) + except FileNotFoundError: + pass + except OSError: + LOGGER.debug("Failed to read existing alias launcher.") + + launcher_remap = cmd.scratch.setdefault("aliasutils.create_alias.launcher_remap", {}) + + if existing_bytes == launcher_bytes: + # Valid existing launcher, so save its path in case we need it later + # for a hard link. + launcher_remap.setdefault(launcher.name, p) + else: + # First try and create a hard link + unlink(p) + try: + _link(launcher, p) + LOGGER.debug("Created %s as hard link to %s", p.name, launcher.name) + except OSError as ex: + if ex.winerror != 17: + # Report errors other than cross-drive links + LOGGER.debug("Failed to create hard link for command.", exc_info=True) + launcher2 = launcher_remap.get(launcher.name) + if launcher2: + try: + _link(launcher2, p) + LOGGER.debug("Created %s as hard link to %s", p.name, launcher2.name) + except FileNotFoundError: + raise + except OSError: + LOGGER.debug("Failed to create hard link to fallback launcher") + launcher2 = None + if not launcher2: + try: + p.write_bytes(launcher_bytes) + LOGGER.debug("Created %s as copy of %s", p.name, launcher.name) + launcher_remap[launcher.name] = p + except OSError: + LOGGER.error("Failed to create global command %s.", alias["name"]) + LOGGER.debug(exc_info=True) + + p_target = p.with_name(p.name + ".__target__") + do_update = True + try: + do_update = not target.match(p_target.read_text(encoding="utf-8")) + except FileNotFoundError: + pass + except (OSError, UnicodeDecodeError): + LOGGER.debug("Failed to read existing target path.", exc_info=True) + + if do_update: + p_target.write_text(str(target), encoding="utf-8") + + p_script = p.with_name(p.name + "-script.py") + if script_code: + do_update = True + try: + do_update = p_script.read_text(encoding="utf-8") == script_code + except FileNotFoundError: + pass + except (OSError, UnicodeDecodeError): + LOGGER.debug("Failed to read existing script file.", exc_info=True) + if do_update: + p_script.write_text(script_code, encoding="utf-8") + else: + try: + unlink(p_script) + except OSError: + LOGGER.error("Failed to clean up existing alias. Re-run with -v " + "or check the install log for details.") + LOGGER.info("Failed to remove %s.", p_script, exc_info=True) + + +def _parse_entrypoint_line(line): + name, sep, rest = line.partition("=") + name = name.strip() + if name and sep and rest: + mod, sep, rest = rest.partition(":") + mod = mod.strip() + if mod and sep and rest: + func, sep, extra = rest.partition("[") + func = func.strip() + if func: + return name, mod, func + return None, None, None + + +def _scan(prefix, dirs): + for dirname in dirs or (): + root = prefix / dirname + + # Scan d for dist-info directories with entry_points.txt + dist_info = [d for d in root.listdir() if d.match("*.dist-info") and d.is_dir()] + LOGGER.debug("Found %i dist-info directories in %s", len(dist_info), root) + entrypoints = [f for f in [d / "entry_points.txt" for d in dist_info] if f.is_file()] + LOGGER.debug("Found %i entry_points.txt files in %s", len(entrypoints), root) + + # Filter down to [console_scripts] and [gui_scripts] + for ep in entrypoints: + try: + f = open(ep, "r", encoding="utf-8", errors="strict") + except OSError: + LOGGER.debug("Failed to read %s", ep, exc_info=True) + continue + + with f: + alias = None + for line in f: + if line.strip() == "[console_scripts]": + alias = dict(windowed=0) + elif line.strip() == "[gui_scripts]": + alias = dict(windowed=1) + elif line.lstrip().startswith("["): + alias = None + elif alias is not None: + name, mod, func = _parse_entrypoint_line(line) + if name and mod and func: + yield ( + {**alias, "name": name}, + f"import sys; from {mod} import {func}; sys.exit({func}())", + ) + + +def scan_and_create_entrypoints(cmd, install, shortcut, _create_alias=create_alias): + prefix = install["prefix"] + known = cmd.scratch.setdefault("entrypointutils.known", set()) + + aliases = list(install.get("alias", ())) + alias_1 = [a for a in aliases if not a.get("windowed")] + alias_2 = [a for a in aliases if a.get("windowed")] + + # If no windowed targets, we'll use the non-windowed one + targets = [prefix / a["target"] for a in [*alias_1[:1], *alias_2[:1], *alias_1[:1]]] + if len(targets) < 2: + LOGGER.debug("No suitable alias found for %s. Skipping entrypoints", + install["id"]) + return + + for alias, code in _scan(prefix, shortcut.get("dirs")): + # Only create names once per install command + n = alias["name"].casefold() + if n in known: + continue + known.add(n) + + # Copy the launcher template and create a standard __target__ file + _create_alias(cmd, install, alias, targets[alias.get("windowed", 0)], + script_code=code) + + +def cleanup_entrypoints(cmd, install_shortcut_pairs): + seen_names = set() + for install, shortcut in install_shortcut_pairs: + for alias, code in _scan(install["prefix"], shortcut.get("dirs")): + seen_names.add(alias["name"].casefold()) + + # Scan existing aliases + scripts = cmd.global_dir.glob("*-script.py") + + # Excluding any in seen_names, delete unused aliases + for script in scripts: + name = script.name.rpartition("-")[0] + if name.casefold() in seen_names: + continue + + alias = cmd.global_dir / (name + ".exe") + if not alias.is_file(): + continue + + try: + unlink(alias) + LOGGER.debug("Deleted %s", alias) + except OSError: + LOGGER.warn("Failed to delete %s", alias) + try: + unlink(script) + LOGGER.debug("Deleted %s", script) + except OSError: + LOGGER.warn("Failed to delete %s", script) diff --git a/src/manage/entrypointutils.py b/src/manage/entrypointutils.py deleted file mode 100644 index 289d3c7..0000000 --- a/src/manage/entrypointutils.py +++ /dev/null @@ -1,33 +0,0 @@ - -def _scan(prefix, dirs): - for dirname in dirs or (): - # TODO: Handle invalid entries - d = install["prefix"] / dirname - - # TODO: Scan d for dist-info directories with entry_points.txt - # Filter down to [console_scripts] and [gui_scripts] - - # TODO: Yield the alias name and script contents - # import sys; from import ; sys.exit(()) - - -def scan_and_create(cmd, install, shortcut): - for name, code in _scan(install["prefix"], shortcut.get("dirs")): - # TODO: Store name in cmd's metadata. - # If it's already been stored, skip all further processing. - - # TOOD: Copy the launcher template and create a standard __target__ file - # Also create an -script.py file containing code - # pymanager/launcher.cpp wil need to be updated to use this script. - # Regular alias creation will need to delete these scripts. - - -def cleanup(cmd, install_shortcut_pairs): - seen_names = set() - for install, shortcut in install_shortcut_pairs: - for name, code in _scan(install["prefix"], shortcut.get("dirs")): - seen_names.add(name) - - # TODO: Scan existing aliases, filter to those with -script.py files - - # TODO: Excluding any in seen_names, delete unused aliases diff --git a/src/manage/install_command.py b/src/manage/install_command.py index a5a5f43..b412e4b 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -217,106 +217,6 @@ def _calc(prefix, filename, calculate_dest=calculate_dest): LOGGER.debug("Attempted to overwrite: %s", dest) -def _if_exists(launcher, plat): - suffix = "." + launcher.suffix.lstrip(".") - plat_launcher = launcher.parent / f"{launcher.stem}{plat}{suffix}" - if plat_launcher.is_file(): - return plat_launcher - return launcher - - -def _write_alias(cmd, install, alias, target, _link=os.link): - p = (cmd.global_dir / alias["name"]) - target = Path(target) - ensure_tree(p) - launcher = cmd.launcher_exe - if alias.get("windowed"): - launcher = cmd.launcherw_exe or launcher - - plat = install["tag"].rpartition("-")[-1] - if plat: - LOGGER.debug("Checking for launcher for platform -%s", plat) - launcher = _if_exists(launcher, f"-{plat}") - if not launcher.is_file(): - LOGGER.debug("Checking for launcher for default platform %s", cmd.default_platform) - launcher = _if_exists(launcher, cmd.default_platform) - if not launcher.is_file(): - LOGGER.debug("Checking for launcher for -64") - launcher = _if_exists(launcher, "-64") - LOGGER.debug("Create %s linking to %s using %s", alias["name"], target, launcher) - if not launcher or not launcher.is_file(): - if install_matches_any(install, getattr(cmd, "tags", None)): - LOGGER.warn("Skipping %s alias because the launcher template was not found.", alias["name"]) - else: - LOGGER.debug("Skipping %s alias because the launcher template was not found.", alias["name"]) - return - - try: - launcher_bytes = launcher.read_bytes() - except OSError: - warnings_shown = cmd.scratch.setdefault("install_command._write_alias.warnings_shown", set()) - if str(launcher) not in warnings_shown: - LOGGER.warn("Failed to read launcher template at %s.", launcher) - warnings_shown.add(str(launcher)) - LOGGER.debug("Failed to read %s", launcher, exc_info=True) - return - - existing_bytes = b'' - try: - with open(p, 'rb') as f: - existing_bytes = f.read(len(launcher_bytes) + 1) - except FileNotFoundError: - pass - except OSError: - LOGGER.debug("Failed to read existing alias launcher.") - - launcher_remap = cmd.scratch.setdefault("install_command._write_alias.launcher_remap", {}) - - if existing_bytes == launcher_bytes: - # Valid existing launcher, so save its path in case we need it later - # for a hard link. - launcher_remap.setdefault(launcher.name, p) - else: - # First try and create a hard link - unlink(p) - try: - _link(launcher, p) - LOGGER.debug("Created %s as hard link to %s", p.name, launcher.name) - except OSError as ex: - if ex.winerror != 17: - # Report errors other than cross-drive links - LOGGER.debug("Failed to create hard link for command.", exc_info=True) - launcher2 = launcher_remap.get(launcher.name) - if launcher2: - try: - _link(launcher2, p) - LOGGER.debug("Created %s as hard link to %s", p.name, launcher2.name) - except FileNotFoundError: - raise - except OSError: - LOGGER.debug("Failed to create hard link to fallback launcher") - launcher2 = None - if not launcher2: - try: - p.write_bytes(launcher_bytes) - LOGGER.debug("Created %s as copy of %s", p.name, launcher.name) - launcher_remap[launcher.name] = p - except OSError: - LOGGER.error("Failed to create global command %s.", alias["name"]) - LOGGER.debug(exc_info=True) - - p_target = p.with_name(p.name + ".__target__") - try: - if target.match(p_target.read_text(encoding="utf-8")): - return - except FileNotFoundError: - pass - except (OSError, UnicodeDecodeError): - LOGGER.debug("Failed to read existing target path.", exc_info=True) - - p_target.write_text(str(target), encoding="utf-8") - - def _create_shortcut_pep514(cmd, install, shortcut): from .pep514utils import update_registry update_registry(cmd.pep514_root, install, shortcut, cmd.tags) @@ -349,13 +249,13 @@ def _cleanup_arp_entries(cmd, install_shortcut_pairs): def _create_entrypoints(cmd, install, shortcut): - from .entrypointutils import scan_and_create - scan_and_create(cmd, install, shortcut) + from .aliasutils import scan_and_create_entrypoints + scan_and_create_entrypoints(cmd, install, shortcut) def _cleanup_entrypoints(cmd, install_shortcut_pairs): - from .entrypointutils import cleanup - cleanup(cmd, install_shortcut_pairs) + from .aliasutils import cleanup_entrypoints + cleanup_entrypoints(cmd, install_shortcut_pairs) SHORTCUT_HANDLERS = { @@ -366,18 +266,20 @@ def _cleanup_entrypoints(cmd, install_shortcut_pairs): } -def update_all_shortcuts(cmd): +def update_all_shortcuts(cmd, *, _create_alias=None): + if not _create_alias: + from .aliasutils import create_alias as _create_alias + LOGGER.debug("Updating global shortcuts") alias_written = set() shortcut_written = {} for i in cmd.get_installs(): if cmd.global_dir: - aliases = i.get("alias", ()) + aliases = list(i.get("alias", ())) # Generate a python.exe for the default runtime in case the user # later disables/removes the global python.exe command. if i.get("default"): - aliases = list(i.get("alias", ())) alias_1 = [a for a in aliases if not a.get("windowed")] alias_2 = [a for a in aliases if a.get("windowed")] if alias_1: @@ -392,7 +294,7 @@ def update_all_shortcuts(cmd): if not target.is_file(): LOGGER.warn("Skipping alias '%s' because target '%s' does not exist", a["name"], a["target"]) continue - _write_alias(cmd, i, a, target) + _create_alias(cmd, i, a, target) alias_written.add(a["name"].casefold()) for s in i.get("shortcuts", ()): diff --git a/src/pymanager/launcher.cpp b/src/pymanager/launcher.cpp index c456c8f..d480ec6 100644 --- a/src/pymanager/launcher.cpp +++ b/src/pymanager/launcher.cpp @@ -142,6 +142,63 @@ get_executable(wchar_t *executable, unsigned int bufferSize) } +int +insert_script(int *argc, wchar_t ***argv) +{ + DWORD len = GetModuleFileNameW(NULL, NULL, 0); + if (len == 0) { + return HRESULT_FROM_WIN32(GetLastError()); + } else if (len < 5) { + return 0; + } + + HANDLE ph = GetProcessHeap(); + DWORD path_len = len + 7; + wchar_t *path = (wchar_t *)HeapAlloc(ph, HEAP_ZERO_MEMORY, sizeof(wchar_t) * path_len); + len = path ? GetModuleFileNameW(NULL, path, path_len) : 0; + if (len == 0) { + return HRESULT_FROM_WIN32(GetLastError()); + } + + if (wcsicmp(&path[len - 4], L".exe")) { + HeapFree(ph, 0, path); + return 0; + } + + wcscpy_s(&path[len - 4], path_len, L"-script.py"); + WIN32_FIND_DATAW fd; + HANDLE fh = FindFirstFileW(path, &fd); + if (fh == INVALID_HANDLE_VALUE) { + int err = GetLastError(); + HeapFree(ph, 0, path); + switch (err) { + case ERROR_INVALID_FUNCTION: + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + return 0; + default: + return HRESULT_FROM_WIN32(GetLastError()); + } + } + CloseHandle(fh); + + wchar_t **argv2 = (wchar_t **)HeapAlloc(ph, HEAP_ZERO_MEMORY, sizeof(wchar_t *) * (*argc + 1)); + if (!argv2) { + HeapFree(ph, 0, path); + return HRESULT_FROM_WIN32(GetLastError()); + } + + // Deliberately letting our memory leak - it'll be cleaned up when the + // process ends, and this is not a loop. + argv2[0] = (*argv)[0]; + argv2[1] = path; + for (int i = 1; i < (*argc); ++i) { + argv2[i + 1] = (*argv)[i]; + } + *argv = argv2; + return 0; +} + int try_load_python3_dll(const wchar_t *executable, unsigned int bufferSize, void **mainFunction) @@ -215,6 +272,11 @@ wmain(int argc, wchar_t **argv) return print_error(err, L"Failed to get target path"); } + err = insert_script(&argc, &argv); + if (err) { + return print_error(err, L"Failed to insert script path"); + } + void *main_func = NULL; err = try_load_python3_dll(executable, MAXLEN, (void **)&main_func); switch (err) { diff --git a/tests/test_alias.py b/tests/test_alias.py new file mode 100644 index 0000000..a99b656 --- /dev/null +++ b/tests/test_alias.py @@ -0,0 +1,218 @@ +import json +import os +import pytest +import secrets +from pathlib import Path, PurePath + +from manage import aliasutils as AU + + +@pytest.fixture +def alias_checker(tmp_path): + with AliasChecker(tmp_path) as checker: + yield checker + + +class AliasChecker: + class Cmd: + global_dir = "out" + launcher_exe = "launcher.txt" + launcherw_exe = "launcherw.txt" + default_platform = "-64" + + def __init__(self, platform=None): + self.scratch = {} + if platform: + self.default_platform = platform + + + def __init__(self, tmp_path): + self.Cmd.global_dir = tmp_path / "out" + self.Cmd.launcher_exe = tmp_path / "launcher.txt" + self.Cmd.launcherw_exe = tmp_path / "launcherw.txt" + self._expect_target = "target-" + secrets.token_hex(32) + self._expect = { + "-32": "-32-" + secrets.token_hex(32), + "-64": "-64-" + secrets.token_hex(32), + "-arm64": "-arm64-" + secrets.token_hex(32), + "w-32": "w-32-" + secrets.token_hex(32), + "w-64": "w-64-" + secrets.token_hex(32), + "w-arm64": "w-arm64-" + secrets.token_hex(32), + } + for k, v in self._expect.items(): + (tmp_path / f"launcher{k}.txt").write_text(v) + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + pass + + def check(self, cmd, tag, name, expect, windowed=0): + AU.create_alias( + cmd, + {"tag": tag}, + {"name": f"{name}.txt", "windowed": windowed}, + self._expect_target, + ) + print(*cmd.global_dir.glob("*"), sep="\n") + assert (cmd.global_dir / f"{name}.txt").is_file() + assert (cmd.global_dir / f"{name}.txt.__target__").is_file() + assert (cmd.global_dir / f"{name}.txt").read_text() == expect + assert (cmd.global_dir / f"{name}.txt.__target__").read_text() == self._expect_target + + def check_32(self, cmd, tag, name): + self.check(cmd, tag, name, self._expect["-32"]) + + def check_w32(self, cmd, tag, name): + self.check(cmd, tag, name, self._expect["w-32"], windowed=1) + + def check_64(self, cmd, tag, name): + self.check(cmd, tag, name, self._expect["-64"]) + + def check_w64(self, cmd, tag, name): + self.check(cmd, tag, name, self._expect["w-64"], windowed=1) + + def check_arm64(self, cmd, tag, name): + self.check(cmd, tag, name, self._expect["-arm64"]) + + def check_warm64(self, cmd, tag, name): + self.check(cmd, tag, name, self._expect["w-arm64"], windowed=1) + + +def test_write_alias_tag_with_platform(alias_checker): + alias_checker.check_32(alias_checker.Cmd(), "1.0-32", "testA") + alias_checker.check_w32(alias_checker.Cmd(), "1.0-32", "testB") + alias_checker.check_64(alias_checker.Cmd(), "1.0-64", "testC") + alias_checker.check_w64(alias_checker.Cmd(), "1.0-64", "testD") + alias_checker.check_arm64(alias_checker.Cmd(), "1.0-arm64", "testE") + alias_checker.check_warm64(alias_checker.Cmd(), "1.0-arm64", "testF") + + +def test_write_alias_default_platform(alias_checker): + alias_checker.check_32(alias_checker.Cmd("-32"), "1.0", "testA") + alias_checker.check_w32(alias_checker.Cmd("-32"), "1.0", "testB") + alias_checker.check_64(alias_checker.Cmd(), "1.0", "testC") + alias_checker.check_w64(alias_checker.Cmd(), "1.0", "testD") + alias_checker.check_arm64(alias_checker.Cmd("-arm64"), "1.0", "testE") + alias_checker.check_warm64(alias_checker.Cmd("-arm64"), "1.0", "testF") + + +def test_write_alias_fallback_platform(alias_checker): + alias_checker.check_64(alias_checker.Cmd("-spam"), "1.0", "testA") + alias_checker.check_w64(alias_checker.Cmd("-spam"), "1.0", "testB") + + +def test_write_alias_launcher_missing(fake_config, assert_log, tmp_path): + fake_config.launcher_exe = tmp_path / "non-existent.exe" + fake_config.default_platform = '-32' + fake_config.global_dir = tmp_path / "bin" + AU.create_alias( + fake_config, + {"tag": "test"}, + {"name": "test.exe"}, + tmp_path / "target.exe", + ) + assert_log( + "Checking for launcher.*", + "Checking for launcher.*", + "Checking for launcher.*", + "Create %s linking to %s", + "Skipping %s alias because the launcher template was not found.", + assert_log.end_of_log(), + ) + + +def test_write_alias_launcher_unreadable(fake_config, assert_log, tmp_path): + class FakeLauncherPath: + stem = "test" + suffix = ".exe" + parent = tmp_path + + @staticmethod + def is_file(): + return True + + @staticmethod + def read_bytes(): + raise OSError("no reading for the test") + + fake_config.scratch = {} + fake_config.launcher_exe = FakeLauncherPath + fake_config.default_platform = '-32' + fake_config.global_dir = tmp_path / "bin" + AU.create_alias( + fake_config, + {"tag": "test"}, + {"name": "test.exe"}, + tmp_path / "target.exe", + ) + assert_log( + "Checking for launcher.*", + "Create %s linking to %s", + "Failed to read launcher template at %s\\.", + "Failed to read %s", + assert_log.end_of_log(), + ) + + +def test_write_alias_launcher_unlinkable(fake_config, assert_log, tmp_path): + def fake_link(x, y): + raise OSError("Error for testing") + + fake_config.scratch = {} + fake_config.launcher_exe = tmp_path / "launcher.txt" + fake_config.launcher_exe.write_bytes(b'Arbitrary contents') + fake_config.default_platform = '-32' + fake_config.global_dir = tmp_path / "bin" + AU.create_alias( + fake_config, + {"tag": "test"}, + {"name": "test.exe"}, + tmp_path / "target.exe", + _link=fake_link + ) + assert_log( + "Checking for launcher.*", + "Create %s linking to %s", + "Failed to create hard link.+", + "Created %s as copy of %s", + assert_log.end_of_log(), + ) + + +def test_write_alias_launcher_unlinkable_remap(fake_config, assert_log, tmp_path): + # This is for the fairly expected case of the PyManager install being on one + # drive, but the global commands directory being on another. In this + # situation, we can't hard link directly into the app files, and will need + # to copy. But we only need to copy once, so if a launcher_remap has been + # set (in the current process), then we have an available copy already and + # can link to that. + + def fake_link(x, y): + if x.match("launcher.txt"): + raise OSError(17, "Error for testing") + + fake_config.scratch = { + "aliasutils.create_alias.launcher_remap": {"launcher.txt": tmp_path / "actual_launcher.txt"}, + } + fake_config.launcher_exe = tmp_path / "launcher.txt" + fake_config.launcher_exe.write_bytes(b'Arbitrary contents') + (tmp_path / "actual_launcher.txt").write_bytes(b'Arbitrary contents') + fake_config.default_platform = '-32' + fake_config.global_dir = tmp_path / "bin" + AU.create_alias( + fake_config, + {"tag": "test"}, + {"name": "test.exe"}, + tmp_path / "target.exe", + _link=fake_link + ) + assert_log( + "Checking for launcher.*", + "Create %s linking to %s", + "Failed to create hard link.+", + ("Created %s as hard link to %s", ("test.exe", "actual_launcher.txt")), + assert_log.end_of_log(), + ) + diff --git a/tests/test_install_command.py b/tests/test_install_command.py index ec48de2..9956995 100644 --- a/tests/test_install_command.py +++ b/tests/test_install_command.py @@ -8,258 +8,6 @@ from manage import installs -@pytest.fixture -def alias_checker(tmp_path): - with AliasChecker(tmp_path) as checker: - yield checker - - -class AliasChecker: - class Cmd: - global_dir = "out" - launcher_exe = "launcher.txt" - launcherw_exe = "launcherw.txt" - default_platform = "-64" - - def __init__(self, platform=None): - self.scratch = {} - if platform: - self.default_platform = platform - - - def __init__(self, tmp_path): - self.Cmd.global_dir = tmp_path / "out" - self.Cmd.launcher_exe = tmp_path / "launcher.txt" - self.Cmd.launcherw_exe = tmp_path / "launcherw.txt" - self._expect_target = "target-" + secrets.token_hex(32) - self._expect = { - "-32": "-32-" + secrets.token_hex(32), - "-64": "-64-" + secrets.token_hex(32), - "-arm64": "-arm64-" + secrets.token_hex(32), - "w-32": "w-32-" + secrets.token_hex(32), - "w-64": "w-64-" + secrets.token_hex(32), - "w-arm64": "w-arm64-" + secrets.token_hex(32), - } - for k, v in self._expect.items(): - (tmp_path / f"launcher{k}.txt").write_text(v) - - def __enter__(self): - return self - - def __exit__(self, *exc_info): - pass - - def check(self, cmd, tag, name, expect, windowed=0): - IC._write_alias( - cmd, - {"tag": tag}, - {"name": f"{name}.txt", "windowed": windowed}, - self._expect_target, - ) - print(*cmd.global_dir.glob("*"), sep="\n") - assert (cmd.global_dir / f"{name}.txt").is_file() - assert (cmd.global_dir / f"{name}.txt.__target__").is_file() - assert (cmd.global_dir / f"{name}.txt").read_text() == expect - assert (cmd.global_dir / f"{name}.txt.__target__").read_text() == self._expect_target - - def check_32(self, cmd, tag, name): - self.check(cmd, tag, name, self._expect["-32"]) - - def check_w32(self, cmd, tag, name): - self.check(cmd, tag, name, self._expect["w-32"], windowed=1) - - def check_64(self, cmd, tag, name): - self.check(cmd, tag, name, self._expect["-64"]) - - def check_w64(self, cmd, tag, name): - self.check(cmd, tag, name, self._expect["w-64"], windowed=1) - - def check_arm64(self, cmd, tag, name): - self.check(cmd, tag, name, self._expect["-arm64"]) - - def check_warm64(self, cmd, tag, name): - self.check(cmd, tag, name, self._expect["w-arm64"], windowed=1) - - -def test_write_alias_tag_with_platform(alias_checker): - alias_checker.check_32(alias_checker.Cmd(), "1.0-32", "testA") - alias_checker.check_w32(alias_checker.Cmd(), "1.0-32", "testB") - alias_checker.check_64(alias_checker.Cmd(), "1.0-64", "testC") - alias_checker.check_w64(alias_checker.Cmd(), "1.0-64", "testD") - alias_checker.check_arm64(alias_checker.Cmd(), "1.0-arm64", "testE") - alias_checker.check_warm64(alias_checker.Cmd(), "1.0-arm64", "testF") - - -def test_write_alias_default_platform(alias_checker): - alias_checker.check_32(alias_checker.Cmd("-32"), "1.0", "testA") - alias_checker.check_w32(alias_checker.Cmd("-32"), "1.0", "testB") - alias_checker.check_64(alias_checker.Cmd(), "1.0", "testC") - alias_checker.check_w64(alias_checker.Cmd(), "1.0", "testD") - alias_checker.check_arm64(alias_checker.Cmd("-arm64"), "1.0", "testE") - alias_checker.check_warm64(alias_checker.Cmd("-arm64"), "1.0", "testF") - - -def test_write_alias_fallback_platform(alias_checker): - alias_checker.check_64(alias_checker.Cmd("-spam"), "1.0", "testA") - alias_checker.check_w64(alias_checker.Cmd("-spam"), "1.0", "testB") - - -def test_write_alias_launcher_missing(fake_config, assert_log, tmp_path): - fake_config.launcher_exe = tmp_path / "non-existent.exe" - fake_config.default_platform = '-32' - fake_config.global_dir = tmp_path / "bin" - IC._write_alias( - fake_config, - {"tag": "test"}, - {"name": "test.exe"}, - tmp_path / "target.exe", - ) - assert_log( - "Checking for launcher.*", - "Checking for launcher.*", - "Checking for launcher.*", - "Create %s linking to %s", - "Skipping %s alias because the launcher template was not found.", - assert_log.end_of_log(), - ) - - -def test_write_alias_launcher_unreadable(fake_config, assert_log, tmp_path): - class FakeLauncherPath: - stem = "test" - suffix = ".exe" - parent = tmp_path - - @staticmethod - def is_file(): - return True - - @staticmethod - def read_bytes(): - raise OSError("no reading for the test") - - fake_config.scratch = {} - fake_config.launcher_exe = FakeLauncherPath - fake_config.default_platform = '-32' - fake_config.global_dir = tmp_path / "bin" - IC._write_alias( - fake_config, - {"tag": "test"}, - {"name": "test.exe"}, - tmp_path / "target.exe", - ) - assert_log( - "Checking for launcher.*", - "Create %s linking to %s", - "Failed to read launcher template at %s\\.", - "Failed to read %s", - assert_log.end_of_log(), - ) - - -def test_write_alias_launcher_unlinkable(fake_config, assert_log, tmp_path): - def fake_link(x, y): - raise OSError("Error for testing") - - fake_config.scratch = {} - fake_config.launcher_exe = tmp_path / "launcher.txt" - fake_config.launcher_exe.write_bytes(b'Arbitrary contents') - fake_config.default_platform = '-32' - fake_config.global_dir = tmp_path / "bin" - IC._write_alias( - fake_config, - {"tag": "test"}, - {"name": "test.exe"}, - tmp_path / "target.exe", - _link=fake_link - ) - assert_log( - "Checking for launcher.*", - "Create %s linking to %s", - "Failed to create hard link.+", - "Created %s as copy of %s", - assert_log.end_of_log(), - ) - - -def test_write_alias_launcher_unlinkable_remap(fake_config, assert_log, tmp_path): - # This is for the fairly expected case of the PyManager install being on one - # drive, but the global commands directory being on another. In this - # situation, we can't hard link directly into the app files, and will need - # to copy. But we only need to copy once, so if a launcher_remap has been - # set (in the current process), then we have an available copy already and - # can link to that. - - def fake_link(x, y): - if x.match("launcher.txt"): - raise OSError(17, "Error for testing") - - fake_config.scratch = { - "install_command._write_alias.launcher_remap": {"launcher.txt": tmp_path / "actual_launcher.txt"}, - } - fake_config.launcher_exe = tmp_path / "launcher.txt" - fake_config.launcher_exe.write_bytes(b'Arbitrary contents') - (tmp_path / "actual_launcher.txt").write_bytes(b'Arbitrary contents') - fake_config.default_platform = '-32' - fake_config.global_dir = tmp_path / "bin" - IC._write_alias( - fake_config, - {"tag": "test"}, - {"name": "test.exe"}, - tmp_path / "target.exe", - _link=fake_link - ) - assert_log( - "Checking for launcher.*", - "Create %s linking to %s", - "Failed to create hard link.+", - ("Created %s as hard link to %s", ("test.exe", "actual_launcher.txt")), - assert_log.end_of_log(), - ) - - -@pytest.mark.parametrize("default", [1, 0]) -def test_write_alias_default(alias_checker, monkeypatch, tmp_path, default): - prefix = Path(tmp_path) / "runtime" - - class Cmd: - global_dir = Path(tmp_path) / "bin" - launcher_exe = None - scratch = {} - def get_installs(self): - return [ - { - "alias": [ - {"name": "python3.exe", "target": "p.exe"}, - {"name": "pythonw3.exe", "target": "pw.exe", "windowed": 1}, - ], - "default": default, - "prefix": prefix, - } - ] - - prefix.mkdir(exist_ok=True, parents=True) - (prefix / "p.exe").write_bytes(b"") - (prefix / "pw.exe").write_bytes(b"") - - written = [] - def write_alias(*a): - written.append(a) - - monkeypatch.setattr(IC, "_write_alias", write_alias) - monkeypatch.setattr(IC, "SHORTCUT_HANDLERS", {}) - - IC.update_all_shortcuts(Cmd()) - - if default: - # Main test: python.exe and pythonw.exe are added in automatically - assert sorted(w[2]["name"] for w in written) == ["python.exe", "python3.exe", "pythonw.exe", "pythonw3.exe"] - else: - assert sorted(w[2]["name"] for w in written) == ["python3.exe", "pythonw3.exe"] - # Ensure we still only have the two targets - assert set(w[3].name for w in written) == {"p.exe", "pw.exe"} - - def test_print_cli_shortcuts(patched_installs, assert_log, monkeypatch, tmp_path): class Cmd: scratch = {} @@ -367,22 +115,28 @@ class Cmd: force = False repair = False - state = IC._preserve_site(Cmd, root) + install = { + "shortcuts": [ + {"kind": "site-dirs", "dirs": ["site-packages"]}, + ], + } + + state = IC._preserve_site(Cmd, root, install) assert not state assert not preserved.exists() Cmd.preserve_site_on_upgrade = True Cmd.force = True - state = IC._preserve_site(Cmd, root) + state = IC._preserve_site(Cmd, root, install) assert not state assert not preserved.exists() Cmd.force = False Cmd.repair = True - state = IC._preserve_site(Cmd, root) + state = IC._preserve_site(Cmd, root, install) assert not state assert not preserved.exists() Cmd.repair = False - state = IC._preserve_site(Cmd, root) + state = IC._preserve_site(Cmd, root, install) assert state == [(site, preserved / "0"), (None, preserved)] assert preserved.is_dir() @@ -395,7 +149,7 @@ class Cmd: assert b"original" == C.read_bytes() assert not preserved.exists() - state = IC._preserve_site(Cmd, root) + state = IC._preserve_site(Cmd, root, install) assert state == [(site, preserved / "0"), (None, preserved)] assert not C.exists() @@ -407,3 +161,48 @@ class Cmd: assert C.is_file() assert b"updated" == C.read_bytes() assert not preserved.exists() + + +@pytest.mark.parametrize("default", [1, 0]) +def test_write_alias_default(monkeypatch, tmp_path, default): + prefix = Path(tmp_path) / "runtime" + + class Cmd: + global_dir = Path(tmp_path) / "bin" + launcher_exe = None + scratch = {} + enable_shortcut_kinds = disable_shortcut_kinds = None + def get_installs(self): + return [ + { + "alias": [ + {"name": "python3.exe", "target": "p.exe"}, + {"name": "pythonw3.exe", "target": "pw.exe", "windowed": 1}, + ], + "default": default, + "prefix": prefix, + } + ] + + prefix.mkdir(exist_ok=True, parents=True) + (prefix / "p.exe").write_bytes(b"") + (prefix / "pw.exe").write_bytes(b"") + + written = [] + def create_alias(*a): + written.append(a) + + monkeypatch.setattr(IC, "SHORTCUT_HANDLERS", { + "site-dirs": (lambda *a: None,) * 2, + }) + + IC.update_all_shortcuts(Cmd(), _create_alias=create_alias) + + if default: + # Main test: python.exe and pythonw.exe are added in automatically + assert sorted(w[2]["name"] for w in written) == ["python.exe", "python3.exe", "pythonw.exe", "pythonw3.exe"] + else: + assert sorted(w[2]["name"] for w in written) == ["python3.exe", "pythonw3.exe"] + # Ensure we still only have the two targets + assert set(w[3].name for w in written) == {"p.exe", "pw.exe"} + From 7ff862646d830b7bdc86eb1a98d734b31e33051c Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 25 Nov 2025 20:24:20 +0000 Subject: [PATCH 03/27] Add test --- src/manage/aliasutils.py | 68 +++++++++++++++++++++------------------- tests/test_alias.py | 39 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 32 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index c3aa734..c7537fa 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -127,9 +127,10 @@ def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link def _parse_entrypoint_line(line): + line = line.partition("#")[0] name, sep, rest = line.partition("=") name = name.strip() - if name and sep and rest: + if name and name[0].isalnum() and sep and rest: mod, sep, rest = rest.partition(":") mod = mod.strip() if mod and sep and rest: @@ -140,40 +141,43 @@ def _parse_entrypoint_line(line): return None, None, None +def _scan_one(root): + # Scan d for dist-info directories with entry_points.txt + dist_info = [d for d in root.glob("*.dist-info") if d.is_dir()] + LOGGER.debug("Found %i dist-info directories in %s", len(dist_info), root) + entrypoints = [f for f in [d / "entry_points.txt" for d in dist_info] if f.is_file()] + LOGGER.debug("Found %i entry_points.txt files in %s", len(entrypoints), root) + + # Filter down to [console_scripts] and [gui_scripts] + for ep in entrypoints: + try: + f = open(ep, "r", encoding="utf-8", errors="strict") + except OSError: + LOGGER.debug("Failed to read %s", ep, exc_info=True) + continue + + with f: + alias = None + for line in f: + if line.strip() == "[console_scripts]": + alias = dict(windowed=0) + elif line.strip() == "[gui_scripts]": + alias = dict(windowed=1) + elif line.lstrip().startswith("["): + alias = None + elif alias is not None: + name, mod, func = _parse_entrypoint_line(line) + if name and mod and func: + yield ( + {**alias, "name": name}, + f"import sys; from {mod} import {func}; sys.exit({func}())", + ) + + def _scan(prefix, dirs): for dirname in dirs or (): root = prefix / dirname - - # Scan d for dist-info directories with entry_points.txt - dist_info = [d for d in root.listdir() if d.match("*.dist-info") and d.is_dir()] - LOGGER.debug("Found %i dist-info directories in %s", len(dist_info), root) - entrypoints = [f for f in [d / "entry_points.txt" for d in dist_info] if f.is_file()] - LOGGER.debug("Found %i entry_points.txt files in %s", len(entrypoints), root) - - # Filter down to [console_scripts] and [gui_scripts] - for ep in entrypoints: - try: - f = open(ep, "r", encoding="utf-8", errors="strict") - except OSError: - LOGGER.debug("Failed to read %s", ep, exc_info=True) - continue - - with f: - alias = None - for line in f: - if line.strip() == "[console_scripts]": - alias = dict(windowed=0) - elif line.strip() == "[gui_scripts]": - alias = dict(windowed=1) - elif line.lstrip().startswith("["): - alias = None - elif alias is not None: - name, mod, func = _parse_entrypoint_line(line) - if name and mod and func: - yield ( - {**alias, "name": name}, - f"import sys; from {mod} import {func}; sys.exit({func}())", - ) + yield from _scan_one(root) def scan_and_create_entrypoints(cmd, install, shortcut, _create_alias=create_alias): diff --git a/tests/test_alias.py b/tests/test_alias.py index a99b656..48769d5 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -216,3 +216,42 @@ def fake_link(x, y): assert_log.end_of_log(), ) + +def test_parse_entrypoint_line(): + for line, expect in [ + ("", (None, None, None)), + ("# comment", (None, None, None)), + ("name-only", (None, None, None)), + ("name=value", (None, None, None)), + ("name=mod:func", ("name", "mod", "func")), + ("name=mod:func#comment", ("name", "mod", "func")), + (" name = mod : func ", ("name", "mod", "func")), + ("name=mod:func[extra]", ("name", "mod", "func")), + ("name=mod:func [extra]", ("name", "mod", "func")), + ]: + assert expect == AU._parse_entrypoint_line(line) + + +def test_scan_entrypoints(tmp_path): + site = tmp_path / "site" + A = site / "a.dist-info" + B = site / "b.dist-info" + A.mkdir(exist_ok=True, parents=True) + B.mkdir(exist_ok=True, parents=True) + (A / "entry_points.txt").write_text("""# Test entries +[console_scripts] +a_cmd = a:main +a2_cmd = a:main2 [spam] + +[gui_scripts] +aw_cmd = a:main +""") + actual = list(AU._scan_one(site)) + assert [a[0]["name"] for a in actual] == [ + "a_cmd", "a2_cmd", "aw_cmd" + ] + assert [a[0]["windowed"] for a in actual] == [0, 0, 1] + assert [a[1].rpartition("; ")[2] for a in actual] == [ + "sys.exit(main())", "sys.exit(main2())", "sys.exit(main())" + ] + From 1263c28bd7cd6c46c2ca8655c1a36811a16dcc6c Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 25 Nov 2025 21:12:38 +0000 Subject: [PATCH 04/27] Improved script and fixed names --- src/manage/aliasutils.py | 65 ++++++++++++++++++++++++++++++++--- src/manage/install_command.py | 17 ++------- src/manage/pathutils.py | 10 ++++-- src/pymanager/launcher.cpp | 53 +++++++++++++++++----------- tests/test_pathutils.py | 15 ++++++++ 5 files changed, 120 insertions(+), 40 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index c7537fa..1a61b4f 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -5,6 +5,37 @@ from .pathutils import Path from .tagutils import install_matches_any +SCRIPT_CODE = """import sys + +# Clear sys.path[0] if it contains this script. +# Be careful to use the most compatible Python code possible. +try: + if sys.path[0]: + if sys.argv[0].startswith(sys.path[0]): + sys.path[0] = "" + else: + open(sys.path[0] + "/" + sys.argv[0], "rb").close() + sys.path[0] = "" +except OSError: + pass +except AttributeError: + pass +except IndexError: + pass + +# Replace argv[0] with our executable instead of the script name. +try: + if sys.argv[0][-14:].upper() == ".__SCRIPT__.PY": + sys.argv[0] = sys.argv[0][:-14] + sys.orig_argv[0] = sys.argv[0] +except AttributeError: + pass +except IndexError: + pass + +from {mod} import {func} +sys.exit({func}()) +""" def _if_exists(launcher, plat): suffix = "." + launcher.suffix.lstrip(".") @@ -15,13 +46,22 @@ def _if_exists(launcher, plat): def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link): - p = (cmd.global_dir / alias["name"]) + p = cmd.global_dir / alias["name"] + if not p.match("*.exe"): + p = p.with_name(p.name + ".exe") target = Path(target) ensure_tree(p) launcher = cmd.launcher_exe if alias.get("windowed"): launcher = cmd.launcherw_exe or launcher + alias_written = cmd.scratch.setdefault("aliasutils.create_alias.alias_written", set()) + n = p.stem.casefold() + if n in alias_written: + # We've already written this alias in this session, so skip it. + return + alias_written.add(n) + plat = install["tag"].rpartition("-")[-1] if plat: LOGGER.debug("Checking for launcher for platform -%s", plat) @@ -60,7 +100,6 @@ def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link LOGGER.debug("Failed to read existing alias launcher.") launcher_remap = cmd.scratch.setdefault("aliasutils.create_alias.launcher_remap", {}) - if existing_bytes == launcher_bytes: # Valid existing launcher, so save its path in case we need it later # for a hard link. @@ -106,7 +145,7 @@ def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link if do_update: p_target.write_text(str(target), encoding="utf-8") - p_script = p.with_name(p.name + "-script.py") + p_script = p.with_name(p.name + ".__script__.py") if script_code: do_update = True try: @@ -126,6 +165,24 @@ def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link LOGGER.info("Failed to remove %s.", p_script, exc_info=True) +def cleanup_alias(cmd): + if not cmd.global_dir or not cmd.global_dir.is_dir(): + return + + alias_written = cmd.scratch.get("aliasutils.create_alias.alias_written") or () + + for alias in cmd.global_dir.glob("*.exe"): + target = alias.with_name(alias.name + ".__target__") + script = alias.with_name(alias.name + ".__script__.py") + if alias.stem.casefold() not in alias_written: + LOGGER.debug("Unlink %s", alias) + unlink(alias, f"Attempting to remove {alias} is taking some time. " + + "Ensure it is not is use, and please continue to wait " + + "or press Ctrl+C to abort.") + unlink(target) + unlink(script) + + def _parse_entrypoint_line(line): line = line.partition("#")[0] name, sep, rest = line.partition("=") @@ -170,7 +227,7 @@ def _scan_one(root): if name and mod and func: yield ( {**alias, "name": name}, - f"import sys; from {mod} import {func}; sys.exit({func}())", + SCRIPT_CODE.format(mod=mod, func=func), ) diff --git a/src/manage/install_command.py b/src/manage/install_command.py index b412e4b..edc1004 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -266,12 +266,12 @@ def _cleanup_entrypoints(cmd, install_shortcut_pairs): } -def update_all_shortcuts(cmd, *, _create_alias=None): +def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): if not _create_alias: from .aliasutils import create_alias as _create_alias + from .aliasutils import cleanup_alias as _cleanup_alias LOGGER.debug("Updating global shortcuts") - alias_written = set() shortcut_written = {} for i in cmd.get_installs(): if cmd.global_dir: @@ -288,14 +288,11 @@ def update_all_shortcuts(cmd, *, _create_alias=None): aliases.append({**alias_2[0], "name": "pythonw.exe"}) for a in aliases: - if a["name"].casefold() in alias_written: - continue target = i["prefix"] / a["target"] if not target.is_file(): LOGGER.warn("Skipping alias '%s' because target '%s' does not exist", a["name"], a["target"]) continue _create_alias(cmd, i, a, target) - alias_written.add(a["name"].casefold()) for s in i.get("shortcuts", ()): if cmd.enable_shortcut_kinds and s["kind"] not in cmd.enable_shortcut_kinds: @@ -321,15 +318,7 @@ def update_all_shortcuts(cmd, *, _create_alias=None): create(cmd, i, s) shortcut_written.setdefault("site-dirs", []).append((i, s)) - if cmd.global_dir and cmd.global_dir.is_dir() and cmd.launcher_exe: - for target in cmd.global_dir.glob("*.exe.__target__"): - alias = target.with_suffix("") - if alias.name.casefold() not in alias_written: - LOGGER.debug("Unlink %s", alias) - unlink(alias, f"Attempting to remove {alias} is taking some time. " + - "Ensure it is not is use, and please continue to wait " + - "or press Ctrl+C to abort.") - target.unlink() + _cleanup_alias(cmd) for k, (_, cleanup) in SHORTCUT_HANDLERS.items(): cleanup(cmd, shortcut_written.get(k, [])) diff --git a/src/manage/pathutils.py b/src/manage/pathutils.py index 8e75d8e..4576573 100644 --- a/src/manage/pathutils.py +++ b/src/manage/pathutils.py @@ -44,11 +44,17 @@ def __bool__(self): @property def stem(self): - return self.name.rpartition(".")[0] + stem, dot, suffix = self.name.rpartition(".") + if not dot: + return suffix + return stem @property def suffix(self): - return self.name.rpartition(".")[2] + stem, dot, suffix = self.name.rpartition(".") + if not dot: + return "" + return dot + suffix @property def parent(self): diff --git a/src/pymanager/launcher.cpp b/src/pymanager/launcher.cpp index d480ec6..0978b9b 100644 --- a/src/pymanager/launcher.cpp +++ b/src/pymanager/launcher.cpp @@ -145,43 +145,56 @@ get_executable(wchar_t *executable, unsigned int bufferSize) int insert_script(int *argc, wchar_t ***argv) { - DWORD len = GetModuleFileNameW(NULL, NULL, 0); - if (len == 0) { - return HRESULT_FROM_WIN32(GetLastError()); - } else if (len < 5) { - return 0; - } - HANDLE ph = GetProcessHeap(); - DWORD path_len = len + 7; - wchar_t *path = (wchar_t *)HeapAlloc(ph, HEAP_ZERO_MEMORY, sizeof(wchar_t) * path_len); - len = path ? GetModuleFileNameW(NULL, path, path_len) : 0; - if (len == 0) { - return HRESULT_FROM_WIN32(GetLastError()); - } + wchar_t *path = NULL; + DWORD path_len = 0; + DWORD len = 0; + int error = 0; + const wchar_t *SUFFIX = L".__script__.py"; + + // Get our path in a dynamic buffer with enough space to add SUFFIX + while (len >= path_len) { + if (path) { + HeapFree(ph, 0, path); + } + path_len += 260; - if (wcsicmp(&path[len - 4], L".exe")) { - HeapFree(ph, 0, path); - return 0; + path = (wchar_t *)HeapAlloc(ph, HEAP_ZERO_MEMORY, sizeof(wchar_t) * path_len); + if (!path) { + return HRESULT_FROM_WIN32(GetLastError()); + } + + len = GetModuleFileNameW(NULL, path, path_len - wcslen(SUFFIX)); + if (len == 0) { + error = GetLastError(); + HeapFree(ph, 0, path); + return HRESULT_FROM_WIN32(error); + } } - wcscpy_s(&path[len - 4], path_len, L"-script.py"); + wcscpy_s(&path[len], path_len, SUFFIX); + + // Check that we have a script file. FindFirstFile should be fastest. WIN32_FIND_DATAW fd; HANDLE fh = FindFirstFileW(path, &fd); if (fh == INVALID_HANDLE_VALUE) { - int err = GetLastError(); + error = GetLastError(); HeapFree(ph, 0, path); - switch (err) { + switch (error) { case ERROR_INVALID_FUNCTION: case ERROR_FILE_NOT_FOUND: case ERROR_PATH_NOT_FOUND: + // This is the typical exit for normal launches. We ought to be nice + // and fast up until this point, but can be slower through every + // other path. return 0; default: - return HRESULT_FROM_WIN32(GetLastError()); + return HRESULT_FROM_WIN32(error); } } CloseHandle(fh); + // Create a new argv that will be used to launch the script. wchar_t **argv2 = (wchar_t **)HeapAlloc(ph, HEAP_ZERO_MEMORY, sizeof(wchar_t *) * (*argc + 1)); if (!argv2) { HeapFree(ph, 0, path); diff --git a/tests/test_pathutils.py b/tests/test_pathutils.py index 5c6f62e..54ebc41 100644 --- a/tests/test_pathutils.py +++ b/tests/test_pathutils.py @@ -15,3 +15,18 @@ def test_path_match(): assert not p.match("example*") assert not p.match("example*.com") assert not p.match("*ple*") + + +def test_path_stem(): + p = Path("python3.12.exe") + assert p.stem == "python3.12" + assert p.suffix == ".exe" + p = Path("python3.12") + assert p.stem == "python3" + assert p.suffix == ".12" + p = Path("python3") + assert p.stem == "python3" + assert p.suffix == "" + p = Path(".exe") + assert p.stem == "" + assert p.suffix == ".exe" From fc4793fa094c49341980b6248831ecc7c7c0e224 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 25 Nov 2025 21:26:18 +0000 Subject: [PATCH 05/27] Fix tests --- tests/conftest.py | 1 + tests/test_alias.py | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6faf08e..3941f01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -155,6 +155,7 @@ def __init__(self, global_dir, installs=[]): self.installs = list(installs) self.shebang_can_run_anything = True self.shebang_can_run_anything_silently = False + self.scratch = {} def get_installs(self, *, include_unmanaged=True, set_default=True): return self.installs diff --git a/tests/test_alias.py b/tests/test_alias.py index 48769d5..2fd1545 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -52,14 +52,14 @@ def check(self, cmd, tag, name, expect, windowed=0): AU.create_alias( cmd, {"tag": tag}, - {"name": f"{name}.txt", "windowed": windowed}, + {"name": name, "windowed": windowed}, self._expect_target, ) print(*cmd.global_dir.glob("*"), sep="\n") - assert (cmd.global_dir / f"{name}.txt").is_file() - assert (cmd.global_dir / f"{name}.txt.__target__").is_file() - assert (cmd.global_dir / f"{name}.txt").read_text() == expect - assert (cmd.global_dir / f"{name}.txt.__target__").read_text() == self._expect_target + assert (cmd.global_dir / f"{name}.exe").is_file() + assert (cmd.global_dir / f"{name}.exe.__target__").is_file() + assert (cmd.global_dir / f"{name}.exe").read_text() == expect + assert (cmd.global_dir / f"{name}.exe.__target__").read_text() == self._expect_target def check_32(self, cmd, tag, name): self.check(cmd, tag, name, self._expect["-32"]) @@ -251,7 +251,7 @@ def test_scan_entrypoints(tmp_path): "a_cmd", "a2_cmd", "aw_cmd" ] assert [a[0]["windowed"] for a in actual] == [0, 0, 1] - assert [a[1].rpartition("; ")[2] for a in actual] == [ - "sys.exit(main())", "sys.exit(main2())", "sys.exit(main())" + assert [a[1].rpartition("sys.exit")[2].strip() for a in actual] == [ + "(main())", "(main2())", "(main())" ] From bd20de54546125f8864c2ca9258ead1f7ff3066d Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 25 Nov 2025 21:30:46 +0000 Subject: [PATCH 06/27] Fix tests --- src/manage/install_command.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/manage/install_command.py b/src/manage/install_command.py index edc1004..39ab32f 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -269,6 +269,7 @@ def _cleanup_entrypoints(cmd, install_shortcut_pairs): def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): if not _create_alias: from .aliasutils import create_alias as _create_alias + if not _cleanup_alias: from .aliasutils import cleanup_alias as _cleanup_alias LOGGER.debug("Updating global shortcuts") From 36118d889ba894e8aa8ff1f65d5174a8ffdd3ae3 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 26 Nov 2025 22:10:33 +0000 Subject: [PATCH 07/27] Add tests to improve coverage --- src/manage/aliasutils.py | 57 ++++++++++++++++------------ tests/test_alias.py | 80 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 24 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index 1a61b4f..cc63ee0 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -149,7 +149,7 @@ def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link if script_code: do_update = True try: - do_update = p_script.read_text(encoding="utf-8") == script_code + do_update = p_script.read_text(encoding="utf-8") != script_code except FileNotFoundError: pass except (OSError, UnicodeDecodeError): @@ -198,6 +198,24 @@ def _parse_entrypoint_line(line): return None, None, None +def _readlines(path): + try: + f = open(path, "r", encoding="utf-8", errors="strict") + except OSError: + LOGGER.debug("Failed to read %s", path, exc_info=True) + return + + with f: + try: + while True: + yield next(f) + except StopIteration: + return + except UnicodeDecodeError: + LOGGER.debug("Failed to decode contents of %s", path, exc_info=True) + return + + def _scan_one(root): # Scan d for dist-info directories with entry_points.txt dist_info = [d for d in root.glob("*.dist-info") if d.is_dir()] @@ -207,28 +225,21 @@ def _scan_one(root): # Filter down to [console_scripts] and [gui_scripts] for ep in entrypoints: - try: - f = open(ep, "r", encoding="utf-8", errors="strict") - except OSError: - LOGGER.debug("Failed to read %s", ep, exc_info=True) - continue - - with f: - alias = None - for line in f: - if line.strip() == "[console_scripts]": - alias = dict(windowed=0) - elif line.strip() == "[gui_scripts]": - alias = dict(windowed=1) - elif line.lstrip().startswith("["): - alias = None - elif alias is not None: - name, mod, func = _parse_entrypoint_line(line) - if name and mod and func: - yield ( - {**alias, "name": name}, - SCRIPT_CODE.format(mod=mod, func=func), - ) + alias = None + for line in _readlines(ep): + if line.strip() == "[console_scripts]": + alias = dict(windowed=0) + elif line.strip() == "[gui_scripts]": + alias = dict(windowed=1) + elif line.lstrip().startswith("["): + alias = None + elif alias is not None: + name, mod, func = _parse_entrypoint_line(line) + if name and mod and func: + yield ( + {**alias, "name": name}, + SCRIPT_CODE.format(mod=mod, func=func), + ) def _scan(prefix, dirs): diff --git a/tests/test_alias.py b/tests/test_alias.py index 2fd1545..5bd2c05 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -48,18 +48,23 @@ def __enter__(self): def __exit__(self, *exc_info): pass - def check(self, cmd, tag, name, expect, windowed=0): + def check(self, cmd, tag, name, expect, windowed=0, script_code=None): AU.create_alias( cmd, {"tag": tag}, {"name": name, "windowed": windowed}, self._expect_target, + script_code=script_code, ) print(*cmd.global_dir.glob("*"), sep="\n") assert (cmd.global_dir / f"{name}.exe").is_file() assert (cmd.global_dir / f"{name}.exe.__target__").is_file() assert (cmd.global_dir / f"{name}.exe").read_text() == expect assert (cmd.global_dir / f"{name}.exe.__target__").read_text() == self._expect_target + if script_code: + assert (cmd.global_dir / f"{name}.exe.__script__.py").is_file() + assert (cmd.global_dir / f"{name}.exe.__script__.py").read_text() == script_code + assert name.casefold() in cmd.scratch["aliasutils.create_alias.alias_written"] def check_32(self, cmd, tag, name): self.check(cmd, tag, name, self._expect["-32"]) @@ -79,6 +84,10 @@ def check_arm64(self, cmd, tag, name): def check_warm64(self, cmd, tag, name): self.check(cmd, tag, name, self._expect["w-arm64"], windowed=1) + def check_script(self, cmd, tag, name, windowed=0): + self.check(cmd, tag, name, self._expect["w-32" if windowed else "-32"], + windowed=windowed, script_code=secrets.token_hex(128)) + def test_write_alias_tag_with_platform(alias_checker): alias_checker.check_32(alias_checker.Cmd(), "1.0-32", "testA") @@ -103,6 +112,12 @@ def test_write_alias_fallback_platform(alias_checker): alias_checker.check_w64(alias_checker.Cmd("-spam"), "1.0", "testB") +def test_write_script_alias(alias_checker): + alias_checker.check_script(alias_checker.Cmd(), "1.0-32", "testA", windowed=0) + alias_checker.check_script(alias_checker.Cmd(), "1.0-32", "testB", windowed=1) + alias_checker.check_script(alias_checker.Cmd(), "1.0-32", "testA", windowed=0) + + def test_write_alias_launcher_missing(fake_config, assert_log, tmp_path): fake_config.launcher_exe = tmp_path / "non-existent.exe" fake_config.default_platform = '-32' @@ -232,6 +247,38 @@ def test_parse_entrypoint_line(): assert expect == AU._parse_entrypoint_line(line) +def test_scan_create_entrypoints(fake_config, tmp_path): + root = tmp_path / "test_install" + site = root / "site-packages" + A = site / "A.dist-info" + A.mkdir(parents=True, exist_ok=True) + (A / "entry_points.txt").write_text("""[console_scripts] +a = a:main + +[gui_scripts] +aw = a:main +""") + + install = dict(prefix=root, id="test", alias=[dict(target="target.exe")]) + + created = [] + AU.scan_and_create_entrypoints( + fake_config, + install, + dict(dirs=["site-packages"]), + _create_alias=lambda *a, **kw: created.append((a, kw)), + ) + assert 2 == len(created) + for name, windowed, c in zip("a aw".split(), [0, 1], created): + expect = dict(zip("cmd install alias target".split(), c[0])) | c[1] + assert expect["cmd"] is fake_config + assert expect["install"] is install + assert expect["alias"]["name"] == name + assert expect["alias"]["windowed"] == windowed + assert expect["target"].match("target.exe") + assert "from a import main" in expect["script_code"] + + def test_scan_entrypoints(tmp_path): site = tmp_path / "site" A = site / "a.dist-info" @@ -243,8 +290,18 @@ def test_scan_entrypoints(tmp_path): a_cmd = a:main a2_cmd = a:main2 [spam] +[other] # shouldn't be included +a3_cmd = a:main3 + [gui_scripts] aw_cmd = a:main +""") + (B / "entry_points.txt").write_bytes(b"""# Invalid file + +\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89 + +[console_scripts] +b_cmd = b:main """) actual = list(AU._scan_one(site)) assert [a[0]["name"] for a in actual] == [ @@ -255,3 +312,24 @@ def test_scan_entrypoints(tmp_path): "(main())", "(main2())", "(main())" ] + +def test_cleanup_aliases(fake_config): + root = fake_config.global_dir + root.mkdir(parents=True, exist_ok=True) + (root / "alias1.exe").write_bytes(b"") + (root / "alias1.exe.__target__").write_bytes(b"") + (root / "alias1.exe.__script__.py").write_bytes(b"") + (root / "alias2.exe").write_bytes(b"") + (root / "alias2.exe.__target__").write_bytes(b"") + (root / "alias2.exe.__script__.py").write_bytes(b"") + (root / "alias3.exe").write_bytes(b"") + (root / "alias3.exe.__target__").write_bytes(b"") + fake_config.scratch["aliasutils.create_alias.alias_written"] = set([ + "alias1".casefold(), + "alias3".casefold(), + ]) + AU.cleanup_alias(fake_config) + assert set(f.name for f in root.glob("*")) == set([ + "alias1.exe", "alias1.exe.__target__", "alias1.exe.__script__.py", + "alias3.exe", "alias3.exe.__target__", + ]) From 81808f3d1687ee12169be4879bc4d4cc6a39ace1 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 26 Nov 2025 23:04:31 +0000 Subject: [PATCH 08/27] More test coverage --- src/manage/aliasutils.py | 22 +++++++++++++++------- tests/test_alias.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index cc63ee0..de51de7 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -248,17 +248,21 @@ def _scan(prefix, dirs): yield from _scan_one(root) -def scan_and_create_entrypoints(cmd, install, shortcut, _create_alias=create_alias): +def scan_and_create_entrypoints(cmd, install, shortcut, _create_alias=create_alias, _scan=_scan): prefix = install["prefix"] known = cmd.scratch.setdefault("entrypointutils.known", set()) aliases = list(install.get("alias", ())) alias_1 = [a for a in aliases if not a.get("windowed")] - alias_2 = [a for a in aliases if a.get("windowed")] - # If no windowed targets, we'll use the non-windowed one - targets = [prefix / a["target"] for a in [*alias_1[:1], *alias_2[:1], *alias_1[:1]]] - if len(targets) < 2: + alias_2 = [a for a in aliases if a.get("windowed")] or alias_1 + + targets = [ + (prefix / alias_1[0]["target"]) if alias_1 else None, + (prefix / alias_2[0]["target"]) if alias_2 else None, + ] + + if not any(targets): LOGGER.debug("No suitable alias found for %s. Skipping entrypoints", install["id"]) return @@ -271,8 +275,12 @@ def scan_and_create_entrypoints(cmd, install, shortcut, _create_alias=create_ali known.add(n) # Copy the launcher template and create a standard __target__ file - _create_alias(cmd, install, alias, targets[alias.get("windowed", 0)], - script_code=code) + target = targets[1 if alias.get("windowed", 0) else 0] + if not target: + LOGGER.debug("No suitable alias found for %s. Skipping this " + + "entrypoint", alias["name"]) + continue + _create_alias(cmd, install, alias, target, script_code=code) def cleanup_entrypoints(cmd, install_shortcut_pairs): diff --git a/tests/test_alias.py b/tests/test_alias.py index 5bd2c05..460eabb 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -279,6 +279,46 @@ def test_scan_create_entrypoints(fake_config, tmp_path): assert "from a import main" in expect["script_code"] +@pytest.mark.parametrize("alias_set", ["none", "one", "onew", "two"]) +def test_scan_create_entrypoints_with_alias(fake_config, tmp_path, alias_set): + # In this test, we fake the scan, but vary the set of aliases associated + # with the installed runtime. + # If there are no aliases, we shouldn't create any entrypoints. + # If we have a non-windowed alias, we'll use that for both. + # If we have a windowed alias, we'll only create windowed entrypoints. + # If we have both, we'll use the appropriate one + + def fake_scan(*a): + return [(dict(name="a", windowed=0), "CODE"), + (dict(name="aw", windowed=1), "CODE")] + + alias = { + "none": [], + "one": [dict(target="test.exe", windowed=0)], + "onew": [dict(target="testw.exe", windowed=1)], + "two": [dict(target="test.exe", windowed=0), + dict(target="testw.exe", windowed=1)], + }[alias_set] + + expect = { + "none": [], + "one": [("a", "test.exe"), ("aw", "test.exe")], + "onew": [("aw", "testw.exe")], + "two": [("a", "test.exe"), ("aw", "testw.exe")], + }[alias_set] + + created = [] + AU.scan_and_create_entrypoints( + fake_config, + dict(prefix=fake_config.root, id="test", alias=alias), + {}, + _create_alias=lambda *a, **kw: created.append((a, kw)), + _scan=fake_scan, + ) + names = [(c[0][2]["name"], c[0][3].name) for c in created] + assert names == expect + + def test_scan_entrypoints(tmp_path): site = tmp_path / "site" A = site / "a.dist-info" From 2a3839ba807b29df0a20625ed6f2f5241ccdf635 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Thu, 27 Nov 2025 17:42:57 +0000 Subject: [PATCH 09/27] Minor refactor, improved test coverage --- src/manage/aliasutils.py | 64 ++++++++++++----------------------- src/manage/install_command.py | 11 +++--- tests/test_alias.py | 53 +++++++++++++++++++---------- 3 files changed, 63 insertions(+), 65 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index de51de7..8905af6 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -1,6 +1,7 @@ import os -from .fsutils import ensure_tree, unlink +from .exceptions import FilesInUseError +from .fsutils import atomic_unlink, ensure_tree, unlink from .logging import LOGGER from .pathutils import Path from .tagutils import install_matches_any @@ -165,24 +166,6 @@ def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link LOGGER.info("Failed to remove %s.", p_script, exc_info=True) -def cleanup_alias(cmd): - if not cmd.global_dir or not cmd.global_dir.is_dir(): - return - - alias_written = cmd.scratch.get("aliasutils.create_alias.alias_written") or () - - for alias in cmd.global_dir.glob("*.exe"): - target = alias.with_name(alias.name + ".__target__") - script = alias.with_name(alias.name + ".__script__.py") - if alias.stem.casefold() not in alias_written: - LOGGER.debug("Unlink %s", alias) - unlink(alias, f"Attempting to remove {alias} is taking some time. " + - "Ensure it is not is use, and please continue to wait " + - "or press Ctrl+C to abort.") - unlink(target) - unlink(script) - - def _parse_entrypoint_line(line): line = line.partition("#")[0] name, sep, rest = line.partition("=") @@ -283,32 +266,27 @@ def scan_and_create_entrypoints(cmd, install, shortcut, _create_alias=create_ali _create_alias(cmd, install, alias, target, script_code=code) -def cleanup_entrypoints(cmd, install_shortcut_pairs): - seen_names = set() - for install, shortcut in install_shortcut_pairs: - for alias, code in _scan(install["prefix"], shortcut.get("dirs")): - seen_names.add(alias["name"].casefold()) +def cleanup_alias(cmd, site_dirs_written, *, _unlink_many=atomic_unlink, _scan=_scan): + if not cmd.global_dir or not cmd.global_dir.is_dir(): + return - # Scan existing aliases - scripts = cmd.global_dir.glob("*-script.py") + expected = set() + for i in cmd.get_installs(): + expected.update(a.get("name", "").casefold() for a in i.get("alias", ())) - # Excluding any in seen_names, delete unused aliases - for script in scripts: - name = script.name.rpartition("-")[0] - if name.casefold() in seen_names: - continue + for i, s in site_dirs_written or (): + for alias, code in _scan(i["prefix"], s.get("dirs")): + expected.add(alias.get("name", "").casefold()) - alias = cmd.global_dir / (name + ".exe") - if not alias.is_file(): + for alias in cmd.global_dir.glob("*.exe"): + if alias.stem.casefold() in expected or alias.name.casefold() in expected: continue - - try: - unlink(alias) - LOGGER.debug("Deleted %s", alias) - except OSError: - LOGGER.warn("Failed to delete %s", alias) + target = alias.with_name(alias.name + ".__target__") + script = alias.with_name(alias.name + ".__script__.py") + LOGGER.debug("Unlink %s", alias) try: - unlink(script) - LOGGER.debug("Deleted %s", script) - except OSError: - LOGGER.warn("Failed to delete %s", script) + _unlink_many([alias, target, script]) + except (OSError, FilesInUseError): + LOGGER.warn("Failed to remove %s. Ensure it is not in use and run " + "py install --refresh to try again.", alias.name) + LOGGER.debug("TRACEBACK", exc_info=True) diff --git a/src/manage/install_command.py b/src/manage/install_command.py index 39ab32f..ea86b31 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -254,8 +254,8 @@ def _create_entrypoints(cmd, install, shortcut): def _cleanup_entrypoints(cmd, install_shortcut_pairs): - from .aliasutils import cleanup_entrypoints - cleanup_entrypoints(cmd, install_shortcut_pairs) + # Entry point aliases are cleaned up with regular aliases + pass SHORTCUT_HANDLERS = { @@ -271,6 +271,7 @@ def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): from .aliasutils import create_alias as _create_alias if not _cleanup_alias: from .aliasutils import cleanup_alias as _cleanup_alias + from .aliasutils import get_site_dirs LOGGER.debug("Updating global shortcuts") shortcut_written = {} @@ -309,7 +310,7 @@ def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): create(cmd, i, s) shortcut_written.setdefault(s["kind"], []).append((i, s)) - # Earlier releases may not have site_dirs. If not, assume + # Earlier releases may not have site_dirs. If not, assume defaults if ("site-dirs" in (cmd.enable_shortcut_kinds or ("site-dirs",)) and "site-dirs" not in (cmd.disable_shortcut_kinds or ()) and all(s["kind"] != "site-dirs" for s in i.get("shortcuts", ()))): @@ -319,11 +320,11 @@ def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): create(cmd, i, s) shortcut_written.setdefault("site-dirs", []).append((i, s)) - _cleanup_alias(cmd) - for k, (_, cleanup) in SHORTCUT_HANDLERS.items(): cleanup(cmd, shortcut_written.get(k, [])) + _cleanup_alias(cmd, shortcut_written.get("site-dirs", [])) + def print_cli_shortcuts(cmd): if cmd.global_dir and cmd.global_dir.is_dir() and any(cmd.global_dir.glob("*.exe")): diff --git a/tests/test_alias.py b/tests/test_alias.py index 460eabb..0fb70af 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -354,22 +354,41 @@ def test_scan_entrypoints(tmp_path): def test_cleanup_aliases(fake_config): + fake_config.installs = [ + dict(id="A", alias=[dict(name="A", target="a.exe")], prefix=fake_config.global_dir), + ] + + def fake_scan(*a): + yield dict(name="B"), "CODE" + + # install/shortcut pairs are irrelevant, since we fake the scan entirely. + # It just can't be empty or the scan is skipped. + pairs = [ + (fake_config.installs[0], dict(kind="site-dirs", dirs=[])), + ] + root = fake_config.global_dir root.mkdir(parents=True, exist_ok=True) - (root / "alias1.exe").write_bytes(b"") - (root / "alias1.exe.__target__").write_bytes(b"") - (root / "alias1.exe.__script__.py").write_bytes(b"") - (root / "alias2.exe").write_bytes(b"") - (root / "alias2.exe.__target__").write_bytes(b"") - (root / "alias2.exe.__script__.py").write_bytes(b"") - (root / "alias3.exe").write_bytes(b"") - (root / "alias3.exe.__target__").write_bytes(b"") - fake_config.scratch["aliasutils.create_alias.alias_written"] = set([ - "alias1".casefold(), - "alias3".casefold(), - ]) - AU.cleanup_alias(fake_config) - assert set(f.name for f in root.glob("*")) == set([ - "alias1.exe", "alias1.exe.__target__", "alias1.exe.__script__.py", - "alias3.exe", "alias3.exe.__target__", - ]) + files = ["A.exe", "A.exe.__target__", + "B.exe", "B.exe.__script__.py", "B.exe.__target__", + "C.exe", "C.exe.__script__.py", "C.exe.__target__"] + for f in files: + (root / f).write_bytes(b"") + + # Ensure the expect files get requested to be unlinked + class Unlinker(list): + def __call__(self, names): + self.extend(names) + + unlinked = Unlinker() + AU.cleanup_alias(fake_config, pairs, _unlink_many=unlinked, _scan=fake_scan) + assert set(f.name for f in unlinked) == set(["C.exe", "C.exe.__script__.py", "C.exe.__target__"]) + + # Ensure we don't break if unlinking fails + def unlink2(names): + raise PermissionError("Simulated error") + AU.cleanup_alias(fake_config, pairs, _unlink_many=unlink2, _scan=fake_scan) + + # Ensure the actual unlink works + AU.cleanup_alias(fake_config, pairs, _scan=fake_scan) + assert set(f.name for f in root.glob("*")) == set(files[:-3]) From 16a60c10eab282424ac6851162f0ddd63cc096da Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Thu, 27 Nov 2025 18:34:44 +0000 Subject: [PATCH 10/27] Remove unused import --- src/manage/install_command.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/manage/install_command.py b/src/manage/install_command.py index ea86b31..559834c 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -271,7 +271,6 @@ def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): from .aliasutils import create_alias as _create_alias if not _cleanup_alias: from .aliasutils import cleanup_alias as _cleanup_alias - from .aliasutils import get_site_dirs LOGGER.debug("Updating global shortcuts") shortcut_written = {} From 52d27a7a1365dc5cd05a5dff57612fbdb2ea9625 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Thu, 27 Nov 2025 19:26:18 +0000 Subject: [PATCH 11/27] Add comment and update scratch key --- src/manage/aliasutils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index 8905af6..8e9f27e 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -231,9 +231,12 @@ def _scan(prefix, dirs): yield from _scan_one(root) -def scan_and_create_entrypoints(cmd, install, shortcut, _create_alias=create_alias, _scan=_scan): +def scan_and_create_entrypoints(cmd, install, shortcut, *, _create_alias=create_alias, _scan=_scan): prefix = install["prefix"] - known = cmd.scratch.setdefault("entrypointutils.known", set()) + + # We will be called multiple times, so need to keep the list of names we've + # already used in this session. + known = cmd.scratch.setdefault("aliasutils.scan_and_create_entrypoints.known", set()) aliases = list(install.get("alias", ())) alias_1 = [a for a in aliases if not a.get("windowed")] From e05fbad2d24efe0503faa0f41aa7a11d1da6b236 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Thu, 27 Nov 2025 19:29:11 +0000 Subject: [PATCH 12/27] Add some missing log messages --- src/manage/aliasutils.py | 2 +- src/manage/install_command.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index 8e9f27e..7b5e15c 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -132,7 +132,7 @@ def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link launcher_remap[launcher.name] = p except OSError: LOGGER.error("Failed to create global command %s.", alias["name"]) - LOGGER.debug(exc_info=True) + LOGGER.debug("TRACEBACK", exc_info=True) p_target = p.with_name(p.name + ".__target__") do_update = True diff --git a/src/manage/install_command.py b/src/manage/install_command.py index 559834c..3722836 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -481,13 +481,13 @@ def _preserve_site(cmd, root, install): LOGGER.info("Preserving %s during update.", d.relative_to(root)) except ValueError: # Just in case a directory goes weird, so we don't break - LOGGER.verbose(exc_info=True) + LOGGER.verbose("Error information:", exc_info=True) LOGGER.verbose("Moving %s to %s", d, target) try: d.rename(target) except OSError: LOGGER.warn("Failed to preserve %s during update.", d) - LOGGER.verbose("TRACEBACK", exc_info=True) + LOGGER.verbose("Error information:", exc_info=True) else: state.append((d, target)) # Append None, target_root last to clean up after restore is done From e621dd483f30a632a29e40d940b36218c1d8160e Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Thu, 27 Nov 2025 20:27:56 +0000 Subject: [PATCH 13/27] Minor bug fixes --- src/manage/aliasutils.py | 4 ++++ src/pymanager/launcher.cpp | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index 7b5e15c..a46d26f 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -277,6 +277,10 @@ def cleanup_alias(cmd, site_dirs_written, *, _unlink_many=atomic_unlink, _scan=_ for i in cmd.get_installs(): expected.update(a.get("name", "").casefold() for a in i.get("alias", ())) + if expected: + expected.add("python".casefold()) + expected.add("pythonw".casefold()) + for i, s in site_dirs_written or (): for alias, code in _scan(i["prefix"], s.get("dirs")): expected.add(alias.get("name", "").casefold()) diff --git a/src/pymanager/launcher.cpp b/src/pymanager/launcher.cpp index 0978b9b..ab2d57a 100644 --- a/src/pymanager/launcher.cpp +++ b/src/pymanager/launcher.cpp @@ -192,7 +192,7 @@ insert_script(int *argc, wchar_t ***argv) return HRESULT_FROM_WIN32(error); } } - CloseHandle(fh); + FindClose(fh); // Create a new argv that will be used to launch the script. wchar_t **argv2 = (wchar_t **)HeapAlloc(ph, HEAP_ZERO_MEMORY, sizeof(wchar_t *) * (*argc + 1)); @@ -209,6 +209,7 @@ insert_script(int *argc, wchar_t ***argv) argv2[i + 1] = (*argv)[i]; } *argv = argv2; + *argc += 1; return 0; } From 5916e76bf3374d6df6ca4a26f8d8063f374a797e Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Thu, 27 Nov 2025 21:09:50 +0000 Subject: [PATCH 14/27] Properly handle launching script executable (not DLL) --- src/pymanager/_launch.cpp | 41 +++++++++++++++++++------------------- src/pymanager/_launch.h | 8 +++++++- src/pymanager/launcher.cpp | 39 +++++++++++++++++++----------------- src/pymanager/main.cpp | 2 +- 4 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/pymanager/_launch.cpp b/src/pymanager/_launch.cpp index 0286093..e34b807 100644 --- a/src/pymanager/_launch.cpp +++ b/src/pymanager/_launch.cpp @@ -34,49 +34,48 @@ dup_handle(HANDLE input, HANDLE *output) int -launch(const wchar_t *executable, const wchar_t *insert_args, int skip_argc, DWORD *exitCode) -{ +launch( + const wchar_t *executable, + const wchar_t *origCmdLine, + const wchar_t *insert_args, + int skip_argc, + DWORD *exitCode +) { HANDLE job; JOBOBJECT_EXTENDED_LIMIT_INFORMATION info; DWORD info_len; STARTUPINFOW si; PROCESS_INFORMATION pi; int lastError = 0; - const wchar_t *arg_space = L" "; - LPCWSTR origCmdLine = GetCommandLineW(); const wchar_t *cmdLine = NULL; - if (insert_args == NULL) { - insert_args = L""; + if (origCmdLine[0] == L'"') { + cmdLine = wcschr(origCmdLine + 1, L'"'); + } else { + cmdLine = wcschr(origCmdLine, L' '); } - size_t n = wcslen(executable) + wcslen(origCmdLine) + wcslen(insert_args) + 5; + size_t n = wcslen(executable) + wcslen(origCmdLine) + wcslen(insert_args) + 6; wchar_t *newCmdLine = (wchar_t *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, n * sizeof(wchar_t)); if (!newCmdLine) { lastError = GetLastError(); goto exit; } - if (origCmdLine[0] == L'"') { - cmdLine = wcschr(origCmdLine + 1, L'"'); - } else { - cmdLine = wcschr(origCmdLine, L' '); - } - + // Skip any requested args, deliberately leaving any trailing spaces + // (we'll skip one later one and add our own space, and preserve multiple) while (skip_argc-- > 0) { wchar_t c; while (*++cmdLine && *cmdLine == L' ') { } while (*++cmdLine && *cmdLine != L' ') { } } - if (!insert_args || !*insert_args) { - arg_space = L""; - } - if (cmdLine && *cmdLine) { - swprintf_s(newCmdLine, n + 1, L"\"%s\"%s%s %s", executable, arg_space, insert_args, cmdLine + 1); - } else { - swprintf_s(newCmdLine, n + 1, L"\"%s\"%s%s", executable, arg_space, insert_args); - } + swprintf_s(newCmdLine, n, L"\"%s\"%s%s%s%s", + executable, + (insert_args && *insert_args) ? L" ": L"", + (insert_args && *insert_args) ? insert_args : L"", + (cmdLine && *cmdLine) ? L" " : L"", + (cmdLine && *cmdLine) ? cmdLine + 1 : L""); #if defined(_WINDOWS) /* diff --git a/src/pymanager/_launch.h b/src/pymanager/_launch.h index 721a06a..9159b63 100644 --- a/src/pymanager/_launch.h +++ b/src/pymanager/_launch.h @@ -1 +1,7 @@ -int launch(const wchar_t *executable, const wchar_t *insert_args, int skip_argc, DWORD *exitCode); +int launch( + const wchar_t *executable, + const wchar_t *origCmdLine, + const wchar_t *insert_args, + int skip_argc, + DWORD *exitCode +); diff --git a/src/pymanager/launcher.cpp b/src/pymanager/launcher.cpp index ab2d57a..33da139 100644 --- a/src/pymanager/launcher.cpp +++ b/src/pymanager/launcher.cpp @@ -143,7 +143,7 @@ get_executable(wchar_t *executable, unsigned int bufferSize) int -insert_script(int *argc, wchar_t ***argv) +get_script(wchar_t **result_path) { HANDLE ph = GetProcessHeap(); wchar_t *path = NULL; @@ -194,22 +194,9 @@ insert_script(int *argc, wchar_t ***argv) } FindClose(fh); - // Create a new argv that will be used to launch the script. - wchar_t **argv2 = (wchar_t **)HeapAlloc(ph, HEAP_ZERO_MEMORY, sizeof(wchar_t *) * (*argc + 1)); - if (!argv2) { - HeapFree(ph, 0, path); - return HRESULT_FROM_WIN32(GetLastError()); - } - // Deliberately letting our memory leak - it'll be cleaned up when the // process ends, and this is not a loop. - argv2[0] = (*argv)[0]; - argv2[1] = path; - for (int i = 1; i < (*argc); ++i) { - argv2[i + 1] = (*argv)[i]; - } - *argv = argv2; - *argc += 1; + *result_path = path; return 0; } @@ -280,21 +267,36 @@ wmain(int argc, wchar_t **argv) { int exit_code; wchar_t executable[MAXLEN]; + wchar_t *script; int err = get_executable(executable, MAXLEN); if (err) { return print_error(err, L"Failed to get target path"); } - err = insert_script(&argc, &argv); + err = get_script(&script); if (err) { - return print_error(err, L"Failed to insert script path"); + return print_error(err, L"Failed to get script path"); } void *main_func = NULL; err = try_load_python3_dll(executable, MAXLEN, (void **)&main_func); switch (err) { case 0: + if (script) { + wchar_t **argv2 = (wchar_t **)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, + (argc + 1) * sizeof(wchar_t *)); + if (!argv2) { + return HRESULT_FROM_WIN32(GetLastError()); + } + argv2[0] = argv[0]; + argv2[1] = script; + for (int i = 1; i < argc; ++i) { + argv2[i + 1] = argv[i]; + } + argv = argv2; + argc += 1; + } err = launch_by_dll(main_func, executable, argc, argv, &exit_code); if (!err) { return exit_code; @@ -324,7 +326,8 @@ wmain(int argc, wchar_t **argv) break; } - err = launch(executable, NULL, 0, (DWORD *)&exit_code); + err = launch(executable, GetCommandLineW(), script, 0, (DWORD *)&exit_code); + if (!err) { return exit_code; } diff --git a/src/pymanager/main.cpp b/src/pymanager/main.cpp index 95512bc..0094df1 100644 --- a/src/pymanager/main.cpp +++ b/src/pymanager/main.cpp @@ -630,7 +630,7 @@ wmain(int argc, wchar_t **argv) } #endif - err = launch(executable.c_str(), args.c_str(), skip_argc, &exitCode); + err = launch(executable.c_str(), GetCommandLineW(), args.c_str(), skip_argc, &exitCode); // TODO: Consider sharing print_error() with launcher.cpp // This will ensure error messages are aligned whether we're launching From 5be8d0444532cb57e1056cdabc2d93e75305d4fd Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 2 Dec 2025 13:41:15 +0000 Subject: [PATCH 15/27] Ensure pip.exe exists --- .github/workflows/build.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 381f730..2830832 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -172,6 +172,22 @@ jobs: PYTHON_MANAGER_CONFIG: .\test-config.json PYMANAGER_DEBUG: true + - name: 'Validate entrypoint script' + run: | + dir pip* + Get-Item .\pip.exe + Get-Item .\pip.exe.__target__ + Get-Content .\pip.exe.__target__ + Get-Item .\pip.exe.__script__.py + Get-Content .\pip.exe.__script__.py + .\pip.exe --version + working-directory: .\test_installs\_bin + env: + PYTHON_MANAGER_INCLUDE_UNMANAGED: false + PYTHON_MANAGER_CONFIG: .\test-config.json + PYMANAGER_DEBUG: true + shell: powershell + - name: 'Offline bundle download and install' run: | pymanager list --online 3 3-32 3-64 3-arm64 From 206951442340b597571de3cd2f6117b7d5aa9ae6 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Dec 2025 14:56:42 +0000 Subject: [PATCH 16/27] Add welcome message --- src/manage/commands.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/manage/commands.py b/src/manage/commands.py index 0ffb8f5..f589bf6 100644 --- a/src/manage/commands.py +++ b/src/manage/commands.py @@ -39,6 +39,10 @@ WELCOME = f"""!B!Python install manager was successfully updated to {__version__}.!W! + +This update adds global shortcuts for installed scripts such as !G!pip.exe!W!. +Use !G!py install --refresh!W! to update all shortcuts. +!Y!This will be needed after installing new scripts, as it is not run automatically.!W! """ # The 'py help' or 'pymanager help' output is constructed by these default docs, From fddc8cc91715b26374ef6f4dbe6d6740feee3bdc Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Dec 2025 17:41:45 +0000 Subject: [PATCH 17/27] Add refresh step to entrypoint test --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a4dcfa1..777447e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -174,6 +174,8 @@ jobs: - name: 'Validate entrypoint script' run: | + del pip* -Verbose + pymanager install --refresh dir pip* Get-Item .\pip.exe Get-Item .\pip.exe.__target__ From c67f0e5e7eb9c045da8ef3a34e1f4bc77e71cb54 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Dec 2025 18:42:32 +0000 Subject: [PATCH 18/27] Fix paths --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 777447e..16b5cb5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -174,6 +174,8 @@ jobs: - name: 'Validate entrypoint script' run: | + $env:PYTHON_MANAGER_CONFIG = (gi $env:PYTHON_MANAGER_CONFIG).FullName + cd .\test_installs\_bin del pip* -Verbose pymanager install --refresh dir pip* @@ -183,7 +185,6 @@ jobs: Get-Item .\pip.exe.__script__.py Get-Content .\pip.exe.__script__.py .\pip.exe --version - working-directory: .\test_installs\_bin env: PYTHON_MANAGER_INCLUDE_UNMANAGED: false PYTHON_MANAGER_CONFIG: .\test-config.json From 7f24d367ce688dda3fbe1503608bf911d8cbeeb8 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Dec 2025 22:41:09 +0000 Subject: [PATCH 19/27] Minor refactoring on alias creation --- src/manage/aliasutils.py | 21 +++++---------------- src/manage/install_command.py | 3 ++- tests/test_alias.py | 18 +++++++++++++++++- tests/test_install_command.py | 1 + 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index a46d26f..e2f1cb5 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -46,7 +46,7 @@ def _if_exists(launcher, plat): return launcher -def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link): +def create_alias(cmd, install, alias, target, aliases_written, *, script_code=None, _link=os.link): p = cmd.global_dir / alias["name"] if not p.match("*.exe"): p = p.with_name(p.name + ".exe") @@ -56,12 +56,11 @@ def create_alias(cmd, install, alias, target, *, script_code=None, _link=os.link if alias.get("windowed"): launcher = cmd.launcherw_exe or launcher - alias_written = cmd.scratch.setdefault("aliasutils.create_alias.alias_written", set()) n = p.stem.casefold() - if n in alias_written: + if n in aliases_written: # We've already written this alias in this session, so skip it. return - alias_written.add(n) + aliases_written.add(n) plat = install["tag"].rpartition("-")[-1] if plat: @@ -231,13 +230,9 @@ def _scan(prefix, dirs): yield from _scan_one(root) -def scan_and_create_entrypoints(cmd, install, shortcut, *, _create_alias=create_alias, _scan=_scan): +def scan_and_create_entrypoints(cmd, install, shortcut, aliases_written, *, _create_alias=create_alias, _scan=_scan): prefix = install["prefix"] - # We will be called multiple times, so need to keep the list of names we've - # already used in this session. - known = cmd.scratch.setdefault("aliasutils.scan_and_create_entrypoints.known", set()) - aliases = list(install.get("alias", ())) alias_1 = [a for a in aliases if not a.get("windowed")] # If no windowed targets, we'll use the non-windowed one @@ -254,19 +249,13 @@ def scan_and_create_entrypoints(cmd, install, shortcut, *, _create_alias=create_ return for alias, code in _scan(prefix, shortcut.get("dirs")): - # Only create names once per install command - n = alias["name"].casefold() - if n in known: - continue - known.add(n) - # Copy the launcher template and create a standard __target__ file target = targets[1 if alias.get("windowed", 0) else 0] if not target: LOGGER.debug("No suitable alias found for %s. Skipping this " + "entrypoint", alias["name"]) continue - _create_alias(cmd, install, alias, target, script_code=code) + _create_alias(cmd, install, alias, target, aliases_written, script_code=code) def cleanup_alias(cmd, site_dirs_written, *, _unlink_many=atomic_unlink, _scan=_scan): diff --git a/src/manage/install_command.py b/src/manage/install_command.py index 3722836..5b4f63d 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -250,7 +250,8 @@ def _cleanup_arp_entries(cmd, install_shortcut_pairs): def _create_entrypoints(cmd, install, shortcut): from .aliasutils import scan_and_create_entrypoints - scan_and_create_entrypoints(cmd, install, shortcut) + aliases_written = cmd.scratch.setdefault("aliasutils.create_alias.aliases_written", set()) + scan_and_create_entrypoints(cmd, install, shortcut, aliases_written) def _cleanup_entrypoints(cmd, install_shortcut_pairs): diff --git a/tests/test_alias.py b/tests/test_alias.py index 0fb70af..0368117 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -49,11 +49,13 @@ def __exit__(self, *exc_info): pass def check(self, cmd, tag, name, expect, windowed=0, script_code=None): + created = set() AU.create_alias( cmd, {"tag": tag}, {"name": name, "windowed": windowed}, self._expect_target, + created, script_code=script_code, ) print(*cmd.global_dir.glob("*"), sep="\n") @@ -64,7 +66,7 @@ def check(self, cmd, tag, name, expect, windowed=0, script_code=None): if script_code: assert (cmd.global_dir / f"{name}.exe.__script__.py").is_file() assert (cmd.global_dir / f"{name}.exe.__script__.py").read_text() == script_code - assert name.casefold() in cmd.scratch["aliasutils.create_alias.alias_written"] + assert name.casefold() in created def check_32(self, cmd, tag, name): self.check(cmd, tag, name, self._expect["-32"]) @@ -122,11 +124,13 @@ def test_write_alias_launcher_missing(fake_config, assert_log, tmp_path): fake_config.launcher_exe = tmp_path / "non-existent.exe" fake_config.default_platform = '-32' fake_config.global_dir = tmp_path / "bin" + created = set() AU.create_alias( fake_config, {"tag": "test"}, {"name": "test.exe"}, tmp_path / "target.exe", + created, ) assert_log( "Checking for launcher.*", @@ -136,6 +140,7 @@ def test_write_alias_launcher_missing(fake_config, assert_log, tmp_path): "Skipping %s alias because the launcher template was not found.", assert_log.end_of_log(), ) + assert "test".casefold() in created def test_write_alias_launcher_unreadable(fake_config, assert_log, tmp_path): @@ -156,11 +161,13 @@ def read_bytes(): fake_config.launcher_exe = FakeLauncherPath fake_config.default_platform = '-32' fake_config.global_dir = tmp_path / "bin" + created = set() AU.create_alias( fake_config, {"tag": "test"}, {"name": "test.exe"}, tmp_path / "target.exe", + created, ) assert_log( "Checking for launcher.*", @@ -169,6 +176,7 @@ def read_bytes(): "Failed to read %s", assert_log.end_of_log(), ) + assert "test".casefold() in created def test_write_alias_launcher_unlinkable(fake_config, assert_log, tmp_path): @@ -180,11 +188,13 @@ def fake_link(x, y): fake_config.launcher_exe.write_bytes(b'Arbitrary contents') fake_config.default_platform = '-32' fake_config.global_dir = tmp_path / "bin" + created = set() AU.create_alias( fake_config, {"tag": "test"}, {"name": "test.exe"}, tmp_path / "target.exe", + created, _link=fake_link ) assert_log( @@ -194,6 +204,7 @@ def fake_link(x, y): "Created %s as copy of %s", assert_log.end_of_log(), ) + assert "test".casefold() in created def test_write_alias_launcher_unlinkable_remap(fake_config, assert_log, tmp_path): @@ -216,11 +227,13 @@ def fake_link(x, y): (tmp_path / "actual_launcher.txt").write_bytes(b'Arbitrary contents') fake_config.default_platform = '-32' fake_config.global_dir = tmp_path / "bin" + created = set() AU.create_alias( fake_config, {"tag": "test"}, {"name": "test.exe"}, tmp_path / "target.exe", + created, _link=fake_link ) assert_log( @@ -230,6 +243,7 @@ def fake_link(x, y): ("Created %s as hard link to %s", ("test.exe", "actual_launcher.txt")), assert_log.end_of_log(), ) + assert "test".casefold() in created def test_parse_entrypoint_line(): @@ -266,6 +280,7 @@ def test_scan_create_entrypoints(fake_config, tmp_path): fake_config, install, dict(dirs=["site-packages"]), + set(), _create_alias=lambda *a, **kw: created.append((a, kw)), ) assert 2 == len(created) @@ -312,6 +327,7 @@ def fake_scan(*a): fake_config, dict(prefix=fake_config.root, id="test", alias=alias), {}, + set(), _create_alias=lambda *a, **kw: created.append((a, kw)), _scan=fake_scan, ) diff --git a/tests/test_install_command.py b/tests/test_install_command.py index 9956995..fb83ae3 100644 --- a/tests/test_install_command.py +++ b/tests/test_install_command.py @@ -103,6 +103,7 @@ def test_preserve_site(tmp_path): preserved = tmp_path / "_root" site = root / "site-packages" not_site = root / "site-not-packages" + not_site.mkdir(parents=True, exist_ok=True) A = site / "A" B = site / "B.txt" C = site / "C.txt" From ea6d7c729f504ad80636a0d8efe77a1418d48f16 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Dec 2025 23:04:43 +0000 Subject: [PATCH 20/27] Fix calls --- src/manage/aliasutils.py | 3 ++- src/manage/install_command.py | 3 ++- tests/test_install_command.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index e2f1cb5..6209f2f 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -50,7 +50,8 @@ def create_alias(cmd, install, alias, target, aliases_written, *, script_code=No p = cmd.global_dir / alias["name"] if not p.match("*.exe"): p = p.with_name(p.name + ".exe") - target = Path(target) + if not isinstance(target, Path): + target = Path(target) ensure_tree(p) launcher = cmd.launcher_exe if alias.get("windowed"): diff --git a/src/manage/install_command.py b/src/manage/install_command.py index 5b4f63d..aaffbbd 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -274,6 +274,7 @@ def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): from .aliasutils import cleanup_alias as _cleanup_alias LOGGER.debug("Updating global shortcuts") + aliases_written = cmd.scratch["aliasutils.create_alias.aliases_written"] = set() shortcut_written = {} for i in cmd.get_installs(): if cmd.global_dir: @@ -294,7 +295,7 @@ def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): if not target.is_file(): LOGGER.warn("Skipping alias '%s' because target '%s' does not exist", a["name"], a["target"]) continue - _create_alias(cmd, i, a, target) + _create_alias(cmd, i, a, target, aliases_written) for s in i.get("shortcuts", ()): if cmd.enable_shortcut_kinds and s["kind"] not in cmd.enable_shortcut_kinds: diff --git a/tests/test_install_command.py b/tests/test_install_command.py index fb83ae3..5250acb 100644 --- a/tests/test_install_command.py +++ b/tests/test_install_command.py @@ -206,4 +206,5 @@ def create_alias(*a): assert sorted(w[2]["name"] for w in written) == ["python3.exe", "pythonw3.exe"] # Ensure we still only have the two targets assert set(w[3].name for w in written) == {"p.exe", "pw.exe"} - + # Ensure we got an empty set passed in each time + assert [w[4] for w in written] == [set()] * len(written) From 198a73f31aadfc51c3f9ca7ff3c4f97b34bbe565 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 9 Dec 2025 00:34:43 +0000 Subject: [PATCH 21/27] Refactor and simplify code for aliases --- src/manage/aliasutils.py | 192 +++++++++++++++++++++---------- src/manage/arputils.py | 2 +- src/manage/exceptions.py | 5 + src/manage/install_command.py | 66 ++++------- src/manage/startutils.py | 5 +- tests/test_alias.py | 205 +++++++++++----------------------- 6 files changed, 229 insertions(+), 246 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index 6209f2f..15fc863 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -1,11 +1,15 @@ import os -from .exceptions import FilesInUseError +from .exceptions import FilesInUseError, NoLauncherTemplateError from .fsutils import atomic_unlink, ensure_tree, unlink from .logging import LOGGER from .pathutils import Path from .tagutils import install_matches_any +_EXE = ".exe".casefold() + +DEFAULT_SITE_DIRS = ["Lib\\site-packages", "Scripts"] + SCRIPT_CODE = """import sys # Clear sys.path[0] if it contains this script. @@ -38,6 +42,33 @@ sys.exit({func}()) """ + +class AliasInfo: + def __init__(self, **kwargs): + self.install = kwargs.get("install") + self.name = kwargs.get("name") + self.windowed = kwargs.get("windowed", 0) + self.target = kwargs.get("target") + self.mod = kwargs.get("mod") + self.func = kwargs.get("func") + + def replace(self, **kwargs): + return AliasInfo(**{ + "install": self.install, + "name": self.name, + "windowed": self.windowed, + "target": self.target, + "mod": self.mod, + "func": self.func, + **kwargs, + }) + + @property + def script_code(self): + if self.mod and self.func: + return SCRIPT_CODE.format(mod=self.mod, func=self.func) + + def _if_exists(launcher, plat): suffix = "." + launcher.suffix.lstrip(".") plat_launcher = launcher.parent / f"{launcher.stem}{plat}{suffix}" @@ -46,24 +77,17 @@ def _if_exists(launcher, plat): return launcher -def create_alias(cmd, install, alias, target, aliases_written, *, script_code=None, _link=os.link): - p = cmd.global_dir / alias["name"] +def _create_alias(cmd, *, name, target, plat=None, windowed=0, script_code=None, _link=os.link): + p = cmd.global_dir / name if not p.match("*.exe"): p = p.with_name(p.name + ".exe") if not isinstance(target, Path): target = Path(target) ensure_tree(p) launcher = cmd.launcher_exe - if alias.get("windowed"): + if windowed: launcher = cmd.launcherw_exe or launcher - n = p.stem.casefold() - if n in aliases_written: - # We've already written this alias in this session, so skip it. - return - aliases_written.add(n) - - plat = install["tag"].rpartition("-")[-1] if plat: LOGGER.debug("Checking for launcher for platform -%s", plat) launcher = _if_exists(launcher, f"-{plat}") @@ -73,13 +97,9 @@ def create_alias(cmd, install, alias, target, aliases_written, *, script_code=No if not launcher.is_file(): LOGGER.debug("Checking for launcher for -64") launcher = _if_exists(launcher, "-64") - LOGGER.debug("Create %s linking to %s using %s", alias["name"], target, launcher) + LOGGER.debug("Create %s linking to %s using %s", name, target, launcher) if not launcher or not launcher.is_file(): - if install_matches_any(install, getattr(cmd, "tags", None)): - LOGGER.warn("Skipping %s alias because the launcher template was not found.", alias["name"]) - else: - LOGGER.debug("Skipping %s alias because the launcher template was not found.", alias["name"]) - return + raise NoLauncherTemplateError() try: launcher_bytes = launcher.read_bytes() @@ -131,7 +151,7 @@ def create_alias(cmd, install, alias, target, aliases_written, *, script_code=No LOGGER.debug("Created %s as copy of %s", p.name, launcher.name) launcher_remap[launcher.name] = p except OSError: - LOGGER.error("Failed to create global command %s.", alias["name"]) + LOGGER.error("Failed to create global command %s.", name) LOGGER.debug("TRACEBACK", exc_info=True) p_target = p.with_name(p.name + ".__target__") @@ -199,12 +219,13 @@ def _readlines(path): return -def _scan_one(root): +def _scan_one(install, root): # Scan d for dist-info directories with entry_points.txt dist_info = [d for d in root.glob("*.dist-info") if d.is_dir()] - LOGGER.debug("Found %i dist-info directories in %s", len(dist_info), root) entrypoints = [f for f in [d / "entry_points.txt" for d in dist_info] if f.is_file()] - LOGGER.debug("Found %i entry_points.txt files in %s", len(entrypoints), root) + if len(entrypoints): + LOGGER.debug("Found %i entry_points.txt files in %i dist-info in %s", + len(entrypoints), len(dist_info), root) # Filter down to [console_scripts] and [gui_scripts] for ep in entrypoints: @@ -219,64 +240,117 @@ def _scan_one(root): elif alias is not None: name, mod, func = _parse_entrypoint_line(line) if name and mod and func: - yield ( - {**alias, "name": name}, - SCRIPT_CODE.format(mod=mod, func=func), - ) + yield AliasInfo(install=install, name=name, + mod=mod, func=func, **alias) -def _scan(prefix, dirs): +def _scan(install, prefix, dirs): for dirname in dirs or (): root = prefix / dirname - yield from _scan_one(root) + yield from _scan_one(install, root) -def scan_and_create_entrypoints(cmd, install, shortcut, aliases_written, *, _create_alias=create_alias, _scan=_scan): - prefix = install["prefix"] +def calculate_aliases(cmd, install, *, _scan=_scan): + LOGGER.debug("Calculating aliases for %s", install["id"]) - aliases = list(install.get("alias", ())) - alias_1 = [a for a in aliases if not a.get("windowed")] - # If no windowed targets, we'll use the non-windowed one - alias_2 = [a for a in aliases if a.get("windowed")] or alias_1 + prefix = install["prefix"] - targets = [ - (prefix / alias_1[0]["target"]) if alias_1 else None, - (prefix / alias_2[0]["target"]) if alias_2 else None, - ] + default_alias = None + default_alias_w = None - if not any(targets): - LOGGER.debug("No suitable alias found for %s. Skipping entrypoints", - install["id"]) + for a in install.get("alias", ()): + target = prefix / a["target"] + if not target.is_file(): + LOGGER.warn("Skipping alias '%s' because target '%s' does not exist", + a["name"], a["target"]) + continue + ai = AliasInfo(install=install, **a) + yield ai + if a.get("windowed") and not default_alias_w: + default_alias_w = ai + if not default_alias: + default_alias = ai + + if not default_alias_w: + default_alias_w = default_alias + + if install.get("default"): + if default_alias: + yield default_alias.replace(name="python") + if default_alias_w: + yield default_alias_w.replace(name="pythonw", windowed=1) + + site_dirs = DEFAULT_SITE_DIRS + for s in install.get("shortcuts", ()): + if s.get("kind") == "site-dirs": + site_dirs = s.get("dirs", ()) + break + + for ai in _scan(install, prefix, site_dirs): + if ai.windowed and default_alias_w: + yield ai.replace(target=default_alias_w.target) + elif not ai.windowed and default_alias: + yield ai.replace(target=default_alias.target) + + +def create_aliases(cmd, aliases, *, _create_alias=_create_alias): + if not cmd.global_dir: return - for alias, code in _scan(prefix, shortcut.get("dirs")): - # Copy the launcher template and create a standard __target__ file - target = targets[1 if alias.get("windowed", 0) else 0] - if not target: - LOGGER.debug("No suitable alias found for %s. Skipping this " + - "entrypoint", alias["name"]) + written = set() + + LOGGER.debug("Creating aliases") + + for alias in aliases: + if not alias.name: + LOGGER.debug("Invalid alias info provided with no name.") + continue + + n = alias.name.casefold().removesuffix(_EXE) + if n in written: + # We've already written this alias, so skip it. continue - _create_alias(cmd, install, alias, target, aliases_written, script_code=code) + written.add(n) + if not alias.target: + LOGGER.debug("No suitable alias found for %s. Skipping", alias.name) + continue -def cleanup_alias(cmd, site_dirs_written, *, _unlink_many=atomic_unlink, _scan=_scan): + target = alias.install["prefix"] / alias.target + try: + _create_alias( + cmd, + install=alias.install, + name=alias.name, + plat=alias.install.get("tag", "").rpartition("-")[2], + target=target, + script_code=alias.script_code, + windowed=alias.windowed, + ) + except NoLauncherTemplateError: + if install_matches_any(alias.install, getattr(cmd, "tags", None)): + LOGGER.warn("Skipping %s alias because " + "the launcher template was not found.", alias.name) + else: + LOGGER.debug("Skipping %s alias because " + "the launcher template was not found.", alias.name) + + + +def cleanup_aliases(cmd, *, preserve, _unlink_many=atomic_unlink): if not cmd.global_dir or not cmd.global_dir.is_dir(): return + LOGGER.debug("Cleaning up aliases") expected = set() - for i in cmd.get_installs(): - expected.update(a.get("name", "").casefold() for a in i.get("alias", ())) - - if expected: - expected.add("python".casefold()) - expected.add("pythonw".casefold()) - - for i, s in site_dirs_written or (): - for alias, code in _scan(i["prefix"], s.get("dirs")): - expected.add(alias.get("name", "").casefold()) + for alias in preserve: + if alias.name: + n = alias.name.casefold().removesuffix(_EXE) + _EXE + expected.add(n) + LOGGER.debug("Retaining %d aliases", len(expected)) for alias in cmd.global_dir.glob("*.exe"): - if alias.stem.casefold() in expected or alias.name.casefold() in expected: + if alias.name.casefold() in expected: continue target = alias.with_name(alias.name + ".__target__") script = alias.with_name(alias.name + ".__script__.py") diff --git a/src/manage/arputils.py b/src/manage/arputils.py index 63ade07..6e98b28 100644 --- a/src/manage/arputils.py +++ b/src/manage/arputils.py @@ -25,7 +25,7 @@ def _self_cmd(): if not appdata: appdata = os.path.expanduser(r"~\AppData\Local") apps = Path(appdata) / r"Microsoft\WindowsApps" - LOGGER.debug("Searching %s for pymanager.exe", apps) + LOGGER.debug("Searching %s for pymanager.exe for ARP entries", apps) for d in apps.iterdir(): if not d.match("PythonSoftwareFoundation.PythonManager_*"): continue diff --git a/src/manage/exceptions.py b/src/manage/exceptions.py index d7892ad..50cd2cf 100644 --- a/src/manage/exceptions.py +++ b/src/manage/exceptions.py @@ -75,3 +75,8 @@ def __init__(self): class FilesInUseError(Exception): def __init__(self, files): self.files = files + + +class NoLauncherTemplateError(Exception): + def __init__(self): + super().__init__("No suitable launcher template was found.") diff --git a/src/manage/install_command.py b/src/manage/install_command.py index aaffbbd..2849594 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -25,8 +25,6 @@ DOWNLOAD_CACHE = {} -DEFAULT_SITE_DIRS = ["Lib\\site-packages", "Scripts"] - def _multihash(file, hashes): import hashlib LOGGER.debug("Calculating hashes: %s", ", ".join(hashes)) @@ -263,40 +261,30 @@ def _cleanup_entrypoints(cmd, install_shortcut_pairs): "pep514": (_create_shortcut_pep514, _cleanup_shortcut_pep514), "start": (_create_start_shortcut, _cleanup_start_shortcut), "uninstall": (_create_arp_entry, _cleanup_arp_entries), - "site-dirs": (_create_entrypoints, _cleanup_entrypoints), + # We want to catch these, but not handle them as regular shortcuts. + "site-dirs": (None, None), } -def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): - if not _create_alias: - from .aliasutils import create_alias as _create_alias - if not _cleanup_alias: - from .aliasutils import cleanup_alias as _cleanup_alias - +def update_all_shortcuts(cmd, *, _aliasutils=None): LOGGER.debug("Updating global shortcuts") - aliases_written = cmd.scratch["aliasutils.create_alias.aliases_written"] = set() + installs = cmd.get_installs() shortcut_written = {} - for i in cmd.get_installs(): - if cmd.global_dir: - aliases = list(i.get("alias", ())) - # Generate a python.exe for the default runtime in case the user - # later disables/removes the global python.exe command. - if i.get("default"): - alias_1 = [a for a in aliases if not a.get("windowed")] - alias_2 = [a for a in aliases if a.get("windowed")] - if alias_1: - aliases.append({**alias_1[0], "name": "python.exe"}) - if alias_2: - aliases.append({**alias_2[0], "name": "pythonw.exe"}) - - for a in aliases: - target = i["prefix"] / a["target"] - if not target.is_file(): - LOGGER.warn("Skipping alias '%s' because target '%s' does not exist", a["name"], a["target"]) - continue - _create_alias(cmd, i, a, target, aliases_written) + if cmd.global_dir: + if not _aliasutils: + from . import aliasutils as _aliasutils + aliases = [] + for i in installs: + try: + aliases.extend(_aliasutils.calculate_aliases(cmd, i)) + except LookupError: + LOGGER.warn("Failed to process aliases for %s.", i["display-name"]) + LOGGER.debug("TRACEBACK", exc_info=True) + _aliasutils.create_aliases(cmd, aliases) + _aliasutils.cleanup_aliases(cmd, preserve=aliases) + for i in installs: for s in i.get("shortcuts", ()): if cmd.enable_shortcut_kinds and s["kind"] not in cmd.enable_shortcut_kinds: continue @@ -308,24 +296,13 @@ def update_all_shortcuts(cmd, *, _create_alias=None, _cleanup_alias=None): LOGGER.warn("Skipping invalid shortcut for '%s'", i["id"]) LOGGER.debug("shortcut: %s", s) else: - create(cmd, i, s) - shortcut_written.setdefault(s["kind"], []).append((i, s)) - - # Earlier releases may not have site_dirs. If not, assume defaults - if ("site-dirs" in (cmd.enable_shortcut_kinds or ("site-dirs",)) and - "site-dirs" not in (cmd.disable_shortcut_kinds or ()) and - all(s["kind"] != "site-dirs" for s in i.get("shortcuts", ()))): - - create, cleanup = SHORTCUT_HANDLERS["site-dirs"] - s = dict(kind="site-dirs", dirs=DEFAULT_SITE_DIRS) - create(cmd, i, s) - shortcut_written.setdefault("site-dirs", []).append((i, s)) + if create: + create(cmd, i, s) + shortcut_written.setdefault(s["kind"], []).append((i, s)) for k, (_, cleanup) in SHORTCUT_HANDLERS.items(): cleanup(cmd, shortcut_written.get(k, [])) - _cleanup_alias(cmd, shortcut_written.get("site-dirs", [])) - def print_cli_shortcuts(cmd): if cmd.global_dir and cmd.global_dir.is_dir() and any(cmd.global_dir.glob("*.exe")): @@ -455,9 +432,10 @@ def _preserve_site(cmd, root, install): state = [] i = 0 + from .aliasutils import DEFAULT_SITE_DIRS site_dirs = DEFAULT_SITE_DIRS for s in install.get("shortcuts", ()): - if s["kind"] == "site-dirs": + if s.get("kind") == "site-dirs": site_dirs = s.get("dirs", ()) break diff --git a/src/manage/startutils.py b/src/manage/startutils.py index 4a45e7c..76349c2 100644 --- a/src/manage/startutils.py +++ b/src/manage/startutils.py @@ -160,7 +160,10 @@ def cleanup(root, preserve, warn_for=[]): LOGGER.debug("Cleaning up Start menu shortcuts") for item in keep: - LOGGER.debug("Except: %s", item) + try: + LOGGER.debug("Except: %s", item.relative_to(root)) + except ValueError: + LOGGER.debug("Except: %s", item) for entry in root.iterdir(): _cleanup(entry, keep) diff --git a/tests/test_alias.py b/tests/test_alias.py index 0368117..2d54a2f 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -5,6 +5,7 @@ from pathlib import Path, PurePath from manage import aliasutils as AU +from manage.exceptions import NoLauncherTemplateError @pytest.fixture @@ -49,14 +50,13 @@ def __exit__(self, *exc_info): pass def check(self, cmd, tag, name, expect, windowed=0, script_code=None): - created = set() - AU.create_alias( + AU._create_alias( cmd, - {"tag": tag}, - {"name": name, "windowed": windowed}, - self._expect_target, - created, + name=name, + plat=tag.rpartition("-")[2], + target=self._expect_target, script_code=script_code, + windowed=windowed, ) print(*cmd.global_dir.glob("*"), sep="\n") assert (cmd.global_dir / f"{name}.exe").is_file() @@ -66,7 +66,6 @@ def check(self, cmd, tag, name, expect, windowed=0, script_code=None): if script_code: assert (cmd.global_dir / f"{name}.exe.__script__.py").is_file() assert (cmd.global_dir / f"{name}.exe.__script__.py").read_text() == script_code - assert name.casefold() in created def check_32(self, cmd, tag, name): self.check(cmd, tag, name, self._expect["-32"]) @@ -124,23 +123,20 @@ def test_write_alias_launcher_missing(fake_config, assert_log, tmp_path): fake_config.launcher_exe = tmp_path / "non-existent.exe" fake_config.default_platform = '-32' fake_config.global_dir = tmp_path / "bin" - created = set() - AU.create_alias( - fake_config, - {"tag": "test"}, - {"name": "test.exe"}, - tmp_path / "target.exe", - created, - ) + with pytest.raises(NoLauncherTemplateError): + AU._create_alias( + fake_config, + name="test.exe", + plat="-64", + target=tmp_path / "target.exe", + ) assert_log( "Checking for launcher.*", "Checking for launcher.*", "Checking for launcher.*", "Create %s linking to %s", - "Skipping %s alias because the launcher template was not found.", assert_log.end_of_log(), ) - assert "test".casefold() in created def test_write_alias_launcher_unreadable(fake_config, assert_log, tmp_path): @@ -161,22 +157,17 @@ def read_bytes(): fake_config.launcher_exe = FakeLauncherPath fake_config.default_platform = '-32' fake_config.global_dir = tmp_path / "bin" - created = set() - AU.create_alias( + AU._create_alias( fake_config, - {"tag": "test"}, - {"name": "test.exe"}, - tmp_path / "target.exe", - created, + name="test.exe", + target=tmp_path / "target.exe", ) assert_log( - "Checking for launcher.*", "Create %s linking to %s", "Failed to read launcher template at %s\\.", "Failed to read %s", assert_log.end_of_log(), ) - assert "test".casefold() in created def test_write_alias_launcher_unlinkable(fake_config, assert_log, tmp_path): @@ -188,23 +179,18 @@ def fake_link(x, y): fake_config.launcher_exe.write_bytes(b'Arbitrary contents') fake_config.default_platform = '-32' fake_config.global_dir = tmp_path / "bin" - created = set() - AU.create_alias( + AU._create_alias( fake_config, - {"tag": "test"}, - {"name": "test.exe"}, - tmp_path / "target.exe", - created, + name="test.exe", + target=tmp_path / "target.exe", _link=fake_link ) assert_log( - "Checking for launcher.*", "Create %s linking to %s", "Failed to create hard link.+", "Created %s as copy of %s", assert_log.end_of_log(), ) - assert "test".casefold() in created def test_write_alias_launcher_unlinkable_remap(fake_config, assert_log, tmp_path): @@ -227,23 +213,18 @@ def fake_link(x, y): (tmp_path / "actual_launcher.txt").write_bytes(b'Arbitrary contents') fake_config.default_platform = '-32' fake_config.global_dir = tmp_path / "bin" - created = set() - AU.create_alias( + AU._create_alias( fake_config, - {"tag": "test"}, - {"name": "test.exe"}, - tmp_path / "target.exe", - created, + name="test.exe", + target=tmp_path / "target.exe", _link=fake_link ) assert_log( - "Checking for launcher.*", "Create %s linking to %s", "Failed to create hard link.+", ("Created %s as hard link to %s", ("test.exe", "actual_launcher.txt")), assert_log.end_of_log(), ) - assert "test".casefold() in created def test_parse_entrypoint_line(): @@ -261,11 +242,12 @@ def test_parse_entrypoint_line(): assert expect == AU._parse_entrypoint_line(line) -def test_scan_create_entrypoints(fake_config, tmp_path): +def test_scan_entrypoints(fake_config, tmp_path): root = tmp_path / "test_install" site = root / "site-packages" A = site / "A.dist-info" A.mkdir(parents=True, exist_ok=True) + (root / "target.exe").write_bytes(b"") (A / "entry_points.txt").write_text("""[console_scripts] a = a:main @@ -273,114 +255,55 @@ def test_scan_create_entrypoints(fake_config, tmp_path): aw = a:main """) - install = dict(prefix=root, id="test", alias=[dict(target="target.exe")]) - - created = [] - AU.scan_and_create_entrypoints( - fake_config, - install, - dict(dirs=["site-packages"]), - set(), - _create_alias=lambda *a, **kw: created.append((a, kw)), - ) - assert 2 == len(created) - for name, windowed, c in zip("a aw".split(), [0, 1], created): - expect = dict(zip("cmd install alias target".split(), c[0])) | c[1] - assert expect["cmd"] is fake_config - assert expect["install"] is install - assert expect["alias"]["name"] == name - assert expect["alias"]["windowed"] == windowed - assert expect["target"].match("target.exe") - assert "from a import main" in expect["script_code"] - - -@pytest.mark.parametrize("alias_set", ["none", "one", "onew", "two"]) -def test_scan_create_entrypoints_with_alias(fake_config, tmp_path, alias_set): - # In this test, we fake the scan, but vary the set of aliases associated - # with the installed runtime. - # If there are no aliases, we shouldn't create any entrypoints. - # If we have a non-windowed alias, we'll use that for both. - # If we have a windowed alias, we'll only create windowed entrypoints. - # If we have both, we'll use the appropriate one - - def fake_scan(*a): - return [(dict(name="a", windowed=0), "CODE"), - (dict(name="aw", windowed=1), "CODE")] - - alias = { - "none": [], - "one": [dict(target="test.exe", windowed=0)], - "onew": [dict(target="testw.exe", windowed=1)], - "two": [dict(target="test.exe", windowed=0), - dict(target="testw.exe", windowed=1)], - }[alias_set] - - expect = { - "none": [], - "one": [("a", "test.exe"), ("aw", "test.exe")], - "onew": [("aw", "testw.exe")], - "two": [("a", "test.exe"), ("aw", "testw.exe")], - }[alias_set] - - created = [] - AU.scan_and_create_entrypoints( - fake_config, - dict(prefix=fake_config.root, id="test", alias=alias), - {}, - set(), - _create_alias=lambda *a, **kw: created.append((a, kw)), - _scan=fake_scan, + install = dict( + prefix=root, + id="test", + default=1, + alias=[dict(name="target", target="target.exe")], + shortcuts=[dict(kind="site-dirs", dirs=["site-packages"])], ) - names = [(c[0][2]["name"], c[0][3].name) for c in created] - assert names == expect + actual = list(AU.calculate_aliases(fake_config, install)) -def test_scan_entrypoints(tmp_path): - site = tmp_path / "site" - A = site / "a.dist-info" - B = site / "b.dist-info" - A.mkdir(exist_ok=True, parents=True) - B.mkdir(exist_ok=True, parents=True) - (A / "entry_points.txt").write_text("""# Test entries -[console_scripts] -a_cmd = a:main -a2_cmd = a:main2 [spam] + assert ["target", "python", "pythonw", "a", "aw"] == [a.name for a in actual] + assert [0, 0, 1, 0, 1] == [a.windowed for a in actual] + assert [None, None, None, "a", "a"] == [a.mod for a in actual] + assert [None, None, None, "main", "main"] == [a.func for a in actual] -[other] # shouldn't be included -a3_cmd = a:main3 -[gui_scripts] -aw_cmd = a:main -""") - (B / "entry_points.txt").write_bytes(b"""# Invalid file +def test_create_aliases(fake_config, tmp_path): + target = tmp_path / "target.exe" + target.write_bytes(b"") -\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89 + created = [] + def _on_create(cmd, **kwargs): + created.append(kwargs) -[console_scripts] -b_cmd = b:main -""") - actual = list(AU._scan_one(site)) - assert [a[0]["name"] for a in actual] == [ - "a_cmd", "a2_cmd", "aw_cmd" - ] - assert [a[0]["windowed"] for a in actual] == [0, 0, 1] - assert [a[1].rpartition("sys.exit")[2].strip() for a in actual] == [ - "(main())", "(main2())", "(main())" + aliases = [ + AU.AliasInfo(install=dict(prefix=tmp_path), name="a", target=target), + AU.AliasInfo(install=dict(prefix=tmp_path), name="a.exe", target=target), + AU.AliasInfo(install=dict(prefix=tmp_path), name="aw", windowed=1, target=target), ] + AU.create_aliases(fake_config, aliases, _create_alias=_on_create) + print(created) + + assert ["a", "aw"] == [a["name"] for a in created] + assert [0, 1] == [a["windowed"] for a in created] + assert [None, None] == [a["script_code"] for a in created] -def test_cleanup_aliases(fake_config): - fake_config.installs = [ - dict(id="A", alias=[dict(name="A", target="a.exe")], prefix=fake_config.global_dir), - ] - def fake_scan(*a): - yield dict(name="B"), "CODE" +def test_cleanup_aliases(fake_config, tmp_path): + target = tmp_path / "target.exe" + target.write_bytes(b"") + + created = [] + def _on_create(cmd, **kwargs): + created.append(kwargs) - # install/shortcut pairs are irrelevant, since we fake the scan entirely. - # It just can't be empty or the scan is skipped. - pairs = [ - (fake_config.installs[0], dict(kind="site-dirs", dirs=[])), + aliases = [ + AU.AliasInfo(install=dict(prefix=tmp_path), name="A", target=target), + AU.AliasInfo(install=dict(prefix=tmp_path), name="B.exe", target=target), ] root = fake_config.global_dir @@ -397,14 +320,14 @@ def __call__(self, names): self.extend(names) unlinked = Unlinker() - AU.cleanup_alias(fake_config, pairs, _unlink_many=unlinked, _scan=fake_scan) + AU.cleanup_aliases(fake_config, preserve=aliases, _unlink_many=unlinked) assert set(f.name for f in unlinked) == set(["C.exe", "C.exe.__script__.py", "C.exe.__target__"]) # Ensure we don't break if unlinking fails def unlink2(names): raise PermissionError("Simulated error") - AU.cleanup_alias(fake_config, pairs, _unlink_many=unlink2, _scan=fake_scan) + AU.cleanup_aliases(fake_config, preserve=aliases, _unlink_many=unlink2) # Ensure the actual unlink works - AU.cleanup_alias(fake_config, pairs, _scan=fake_scan) + AU.cleanup_aliases(fake_config, preserve=aliases) assert set(f.name for f in root.glob("*")) == set(files[:-3]) From 2eec6d2105752acae7c9376413fe5b56c2137544 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 9 Dec 2025 16:55:50 +0000 Subject: [PATCH 22/27] Improved edge case handling and test --- src/manage/install_command.py | 57 ++++++++++++++++++++++++++--------- tests/test_install_command.py | 30 ++++++++++-------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/manage/install_command.py b/src/manage/install_command.py index 2849594..bab52b3 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -216,34 +216,62 @@ def _calc(prefix, filename, calculate_dest=calculate_dest): def _create_shortcut_pep514(cmd, install, shortcut): - from .pep514utils import update_registry - update_registry(cmd.pep514_root, install, shortcut, cmd.tags) + try: + from .pep514utils import update_registry + root = cmd.pep514_root + except (ImportError, AttributeError): + LOGGER.debug("Skipping PEP 514 creation.", exc_info=True) + return + update_registry(root, install, shortcut, cmd.tags) def _cleanup_shortcut_pep514(cmd, install_shortcut_pairs): - from .pep514utils import cleanup_registry - cleanup_registry(cmd.pep514_root, {s["Key"] for i, s in install_shortcut_pairs}, cmd.tags) + try: + from .pep514utils import cleanup_registry + root = cmd.pep514_root + except (ImportError, AttributeError): + LOGGER.debug("Skipping PEP 514 cleanup.", exc_info=True) + return + cleanup_registry(root, {s["Key"] for i, s in install_shortcut_pairs}, getattr(cmd, "tags", None)) def _create_start_shortcut(cmd, install, shortcut): - from .startutils import create_one - create_one(cmd.start_folder, install, shortcut, cmd.tags) + try: + from .startutils import create_one + root = cmd.start_folder + except (ImportError, AttributeError): + LOGGER.debug("Skipping Start shortcut creation.", exc_info=True) + return + create_one(root, install, shortcut, cmd.tags) def _cleanup_start_shortcut(cmd, install_shortcut_pairs): - from .startutils import cleanup - cleanup(cmd.start_folder, [s for i, s in install_shortcut_pairs], cmd.tags) + try: + from .startutils import cleanup + root = cmd.start_folder + except (ImportError, AttributeError): + LOGGER.debug("Skipping Start shortcut cleanup.", exc_info=True) + return + cleanup(root, [s for i, s in install_shortcut_pairs], getattr(cmd, "tags", None)) def _create_arp_entry(cmd, install, shortcut): # ARP = Add/Remove Programs - from .arputils import create_one - create_one(install, shortcut, cmd.tags) + try: + from .arputils import create_one + except ImportError: + LOGGER.debug("Skipping ARP entry creation.", exc_info=True) + return + create_one(install, shortcut, getattr(cmd, "tags", None)) def _cleanup_arp_entries(cmd, install_shortcut_pairs): - from .arputils import cleanup - cleanup([i for i, s in install_shortcut_pairs], cmd.tags) + try: + from .arputils import cleanup + except ImportError: + LOGGER.debug("Skipping ARP entry cleanup.", exc_info=True) + return + cleanup([i for i, s in install_shortcut_pairs], getattr(cmd, "tags", None)) def _create_entrypoints(cmd, install, shortcut): @@ -279,7 +307,7 @@ def update_all_shortcuts(cmd, *, _aliasutils=None): try: aliases.extend(_aliasutils.calculate_aliases(cmd, i)) except LookupError: - LOGGER.warn("Failed to process aliases for %s.", i["display-name"]) + LOGGER.warn("Failed to process aliases for %s.", i.get("display-name", i["id"])) LOGGER.debug("TRACEBACK", exc_info=True) _aliasutils.create_aliases(cmd, aliases) _aliasutils.cleanup_aliases(cmd, preserve=aliases) @@ -301,7 +329,8 @@ def update_all_shortcuts(cmd, *, _aliasutils=None): shortcut_written.setdefault(s["kind"], []).append((i, s)) for k, (_, cleanup) in SHORTCUT_HANDLERS.items(): - cleanup(cmd, shortcut_written.get(k, [])) + if cleanup: + cleanup(cmd, shortcut_written.get(k, [])) def print_cli_shortcuts(cmd): diff --git a/tests/test_install_command.py b/tests/test_install_command.py index 5250acb..4b6c09d 100644 --- a/tests/test_install_command.py +++ b/tests/test_install_command.py @@ -176,6 +176,7 @@ class Cmd: def get_installs(self): return [ { + "id": "test", "alias": [ {"name": "python3.exe", "target": "p.exe"}, {"name": "pythonw3.exe", "target": "pw.exe", "windowed": 1}, @@ -189,22 +190,27 @@ def get_installs(self): (prefix / "p.exe").write_bytes(b"") (prefix / "pw.exe").write_bytes(b"") - written = [] - def create_alias(*a): - written.append(a) - monkeypatch.setattr(IC, "SHORTCUT_HANDLERS", { - "site-dirs": (lambda *a: None,) * 2, - }) + created = [] - IC.update_all_shortcuts(Cmd(), _create_alias=create_alias) + class AliasUtils: + import manage.aliasutils as AU + calculate_aliases = staticmethod(AU.calculate_aliases) + + @staticmethod + def create_aliases(cmd, aliases): + created.extend(aliases) + + @staticmethod + def cleanup_aliases(cmd, preserve): + pass + + IC.update_all_shortcuts(Cmd(), _aliasutils=AliasUtils) if default: # Main test: python.exe and pythonw.exe are added in automatically - assert sorted(w[2]["name"] for w in written) == ["python.exe", "python3.exe", "pythonw.exe", "pythonw3.exe"] + assert sorted(a.name for a in created) == ["python", "python3.exe", "pythonw", "pythonw3.exe"] else: - assert sorted(w[2]["name"] for w in written) == ["python3.exe", "pythonw3.exe"] + assert sorted(a.name for a in created) == ["python3.exe", "pythonw3.exe"] # Ensure we still only have the two targets - assert set(w[3].name for w in written) == {"p.exe", "pw.exe"} - # Ensure we got an empty set passed in each time - assert [w[4] for w in written] == [set()] * len(written) + assert set(a.target for a in created) == {"p.exe", "pw.exe"} From a36f76a62ce402a2aac62c944dedffdbfba45349 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 9 Dec 2025 17:26:05 +0000 Subject: [PATCH 23/27] Update args --- src/manage/aliasutils.py | 1 - tests/test_alias.py | 11 ++++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index 15fc863..579b1f1 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -320,7 +320,6 @@ def create_aliases(cmd, aliases, *, _create_alias=_create_alias): try: _create_alias( cmd, - install=alias.install, name=alias.name, plat=alias.install.get("tag", "").rpartition("-")[2], target=target, diff --git a/tests/test_alias.py b/tests/test_alias.py index 2d54a2f..7dc6238 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -276,8 +276,9 @@ def test_create_aliases(fake_config, tmp_path): target.write_bytes(b"") created = [] - def _on_create(cmd, **kwargs): - created.append(kwargs) + # Full arguments copied from source to ensure callers only pass valid args + def _on_create(cmd, *, name, target, plat=None, windowed=0, script_code=None): + created.append((name, windowed, script_code)) aliases = [ AU.AliasInfo(install=dict(prefix=tmp_path), name="a", target=target), @@ -288,9 +289,9 @@ def _on_create(cmd, **kwargs): AU.create_aliases(fake_config, aliases, _create_alias=_on_create) print(created) - assert ["a", "aw"] == [a["name"] for a in created] - assert [0, 1] == [a["windowed"] for a in created] - assert [None, None] == [a["script_code"] for a in created] + assert ["a", "aw"] == [a[0] for a in created] + assert [0, 1] == [a[1] for a in created] + assert [None, None] == [a[2] for a in created] def test_cleanup_aliases(fake_config, tmp_path): From fb9b7c05b2c4e1af1c17280ff9f752708cd9bda9 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 9 Dec 2025 18:08:17 +0000 Subject: [PATCH 24/27] Remove some dead code --- src/manage/install_command.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/manage/install_command.py b/src/manage/install_command.py index bab52b3..6d82d1c 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -274,17 +274,6 @@ def _cleanup_arp_entries(cmd, install_shortcut_pairs): cleanup([i for i, s in install_shortcut_pairs], getattr(cmd, "tags", None)) -def _create_entrypoints(cmd, install, shortcut): - from .aliasutils import scan_and_create_entrypoints - aliases_written = cmd.scratch.setdefault("aliasutils.create_alias.aliases_written", set()) - scan_and_create_entrypoints(cmd, install, shortcut, aliases_written) - - -def _cleanup_entrypoints(cmd, install_shortcut_pairs): - # Entry point aliases are cleaned up with regular aliases - pass - - SHORTCUT_HANDLERS = { "pep514": (_create_shortcut_pep514, _cleanup_shortcut_pep514), "start": (_create_start_shortcut, _cleanup_start_shortcut), From 13c57b757fde55c3169dd5146d8d9caa3d05bbc3 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 9 Dec 2025 21:02:56 +0000 Subject: [PATCH 25/27] Naming conventions --- src/pymanager/_launch.cpp | 14 +++++++------- src/pymanager/_launch.h | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pymanager/_launch.cpp b/src/pymanager/_launch.cpp index e34b807..8781e4a 100644 --- a/src/pymanager/_launch.cpp +++ b/src/pymanager/_launch.cpp @@ -36,10 +36,10 @@ dup_handle(HANDLE input, HANDLE *output) int launch( const wchar_t *executable, - const wchar_t *origCmdLine, + const wchar_t *orig_cmd_line, const wchar_t *insert_args, int skip_argc, - DWORD *exitCode + DWORD *exit_code ) { HANDLE job; JOBOBJECT_EXTENDED_LIMIT_INFORMATION info; @@ -49,13 +49,13 @@ launch( int lastError = 0; const wchar_t *cmdLine = NULL; - if (origCmdLine[0] == L'"') { - cmdLine = wcschr(origCmdLine + 1, L'"'); + if (orig_cmd_line[0] == L'"') { + cmdLine = wcschr(orig_cmd_line + 1, L'"'); } else { - cmdLine = wcschr(origCmdLine, L' '); + cmdLine = wcschr(orig_cmd_line, L' '); } - size_t n = wcslen(executable) + wcslen(origCmdLine) + wcslen(insert_args) + 6; + size_t n = wcslen(executable) + wcslen(orig_cmd_line) + wcslen(insert_args) + 6; wchar_t *newCmdLine = (wchar_t *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, n * sizeof(wchar_t)); if (!newCmdLine) { lastError = GetLastError(); @@ -130,7 +130,7 @@ launch( AssignProcessToJobObject(job, pi.hProcess); CloseHandle(pi.hThread); WaitForSingleObjectEx(pi.hProcess, INFINITE, FALSE); - if (!GetExitCodeProcess(pi.hProcess, exitCode)) { + if (!GetExitCodeProcess(pi.hProcess, exit_code)) { lastError = GetLastError(); } exit: diff --git a/src/pymanager/_launch.h b/src/pymanager/_launch.h index 9159b63..1f51746 100644 --- a/src/pymanager/_launch.h +++ b/src/pymanager/_launch.h @@ -1,7 +1,7 @@ int launch( const wchar_t *executable, - const wchar_t *origCmdLine, + const wchar_t *orig_cmd_line, const wchar_t *insert_args, int skip_argc, - DWORD *exitCode + DWORD *exit_code ); From 42b51bb4b4fa065ec03d4847e6fa73ecab4bb896 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 9 Dec 2025 21:41:24 +0000 Subject: [PATCH 26/27] Fixes and improvements suggested by reviewer --- src/manage/aliasutils.py | 11 ++++++++++- src/pymanager/_launch.cpp | 32 ++++++++++++++++---------------- src/pymanager/launcher.cpp | 4 ++-- tests/test_alias.py | 7 ------- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index 579b1f1..12aa25d 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -66,6 +66,14 @@ def replace(self, **kwargs): @property def script_code(self): if self.mod and self.func: + if not self.mod.isidentifier(): + LOGGER.warn("Alias %s has an entrypoint with invalid module " + "%r.", self.name, self.mod) + return None + if not self.func.isidentifier(): + LOGGER.warn("Alias %s has an entrypoint with invalid function " + "%r.", self.name, self.func) + return None return SCRIPT_CODE.format(mod=self.mod, func=self.func) @@ -183,7 +191,8 @@ def _create_alias(cmd, *, name, target, plat=None, windowed=0, script_code=None, except OSError: LOGGER.error("Failed to clean up existing alias. Re-run with -v " "or check the install log for details.") - LOGGER.info("Failed to remove %s.", p_script, exc_info=True) + LOGGER.info("Failed to remove %s.", p_script) + LOGGER.debug("TRACEBACK", exc_info=True) def _parse_entrypoint_line(line): diff --git a/src/pymanager/_launch.cpp b/src/pymanager/_launch.cpp index 8781e4a..e4de999 100644 --- a/src/pymanager/_launch.cpp +++ b/src/pymanager/_launch.cpp @@ -47,35 +47,35 @@ launch( STARTUPINFOW si; PROCESS_INFORMATION pi; int lastError = 0; - const wchar_t *cmdLine = NULL; + const wchar_t *cmd_line = NULL; if (orig_cmd_line[0] == L'"') { - cmdLine = wcschr(orig_cmd_line + 1, L'"'); + cmd_line = wcschr(orig_cmd_line + 1, L'"'); } else { - cmdLine = wcschr(orig_cmd_line, L' '); + cmd_line = wcschr(orig_cmd_line, L' '); } - size_t n = wcslen(executable) + wcslen(orig_cmd_line) + wcslen(insert_args) + 6; - wchar_t *newCmdLine = (wchar_t *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, n * sizeof(wchar_t)); - if (!newCmdLine) { + size_t n = wcslen(executable) + wcslen(orig_cmd_line) + (insert_args ? wcslen(insert_args) : 0) + 6; + wchar_t *new_cmd_line = (wchar_t *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, n * sizeof(wchar_t)); + if (!new_cmd_line) { lastError = GetLastError(); goto exit; } // Skip any requested args, deliberately leaving any trailing spaces - // (we'll skip one later one and add our own space, and preserve multiple) - while (skip_argc-- > 0) { + // (we'll skip one later on and add our own space, and preserve multiple) + while (cmd_line && *cmd_line && skip_argc-- > 0) { wchar_t c; - while (*++cmdLine && *cmdLine == L' ') { } - while (*++cmdLine && *cmdLine != L' ') { } + while (*++cmd_line && *cmd_line == L' ') { } + while (*++cmd_line && *cmd_line != L' ') { } } - swprintf_s(newCmdLine, n, L"\"%s\"%s%s%s%s", + swprintf_s(new_cmd_line, n, L"\"%s\"%s%s%s%s", executable, (insert_args && *insert_args) ? L" ": L"", (insert_args && *insert_args) ? insert_args : L"", - (cmdLine && *cmdLine) ? L" " : L"", - (cmdLine && *cmdLine) ? cmdLine + 1 : L""); + (cmd_line && *cmd_line) ? L" " : L"", + (cmd_line && *cmd_line) ? cmd_line + 1 : L""); #if defined(_WINDOWS) /* @@ -122,7 +122,7 @@ launch( } si.dwFlags |= STARTF_USESTDHANDLES; - if (!CreateProcessW(executable, newCmdLine, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) { + if (!CreateProcessW(executable, new_cmd_line, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) { lastError = GetLastError(); goto exit; } @@ -134,8 +134,8 @@ launch( lastError = GetLastError(); } exit: - if (newCmdLine) { - HeapFree(GetProcessHeap(), 0, newCmdLine); + if (new_cmd_line) { + HeapFree(GetProcessHeap(), 0, new_cmd_line); } return lastError ? HRESULT_FROM_WIN32(lastError) : 0; } diff --git a/src/pymanager/launcher.cpp b/src/pymanager/launcher.cpp index 33da139..79e1063 100644 --- a/src/pymanager/launcher.cpp +++ b/src/pymanager/launcher.cpp @@ -172,7 +172,7 @@ get_script(wchar_t **result_path) } } - wcscpy_s(&path[len], path_len, SUFFIX); + wcscpy_s(&path[len], path_len - len, SUFFIX); // Check that we have a script file. FindFirstFile should be fastest. WIN32_FIND_DATAW fd; @@ -267,7 +267,7 @@ wmain(int argc, wchar_t **argv) { int exit_code; wchar_t executable[MAXLEN]; - wchar_t *script; + wchar_t *script = NULL; int err = get_executable(executable, MAXLEN); if (err) { diff --git a/tests/test_alias.py b/tests/test_alias.py index 7dc6238..27da874 100644 --- a/tests/test_alias.py +++ b/tests/test_alias.py @@ -1,8 +1,5 @@ -import json -import os import pytest import secrets -from pathlib import Path, PurePath from manage import aliasutils as AU from manage.exceptions import NoLauncherTemplateError @@ -298,10 +295,6 @@ def test_cleanup_aliases(fake_config, tmp_path): target = tmp_path / "target.exe" target.write_bytes(b"") - created = [] - def _on_create(cmd, **kwargs): - created.append(kwargs) - aliases = [ AU.AliasInfo(install=dict(prefix=tmp_path), name="A", target=target), AU.AliasInfo(install=dict(prefix=tmp_path), name="B.exe", target=target), From 753b29537a83072a327e0288c6c9ee38f0ed710e Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 9 Dec 2025 22:48:31 +0000 Subject: [PATCH 27/27] Split names before testing --- src/manage/aliasutils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/manage/aliasutils.py b/src/manage/aliasutils.py index 12aa25d..e76e777 100644 --- a/src/manage/aliasutils.py +++ b/src/manage/aliasutils.py @@ -66,11 +66,11 @@ def replace(self, **kwargs): @property def script_code(self): if self.mod and self.func: - if not self.mod.isidentifier(): + if not all(s.isidentifier() for s in self.mod.split(".")): LOGGER.warn("Alias %s has an entrypoint with invalid module " "%r.", self.name, self.mod) return None - if not self.func.isidentifier(): + if not all(s.isidentifier() for s in self.func.split(".")): LOGGER.warn("Alias %s has an entrypoint with invalid function " "%r.", self.name, self.func) return None