diff --git a/src/manage/config.py b/src/manage/config.py index c9500be..5b83095 100644 --- a/src/manage/config.py +++ b/src/manage/config.py @@ -131,7 +131,13 @@ def load_registry_config(key_path, schema): "This is very unexpected. Please check your configuration " + "or report an issue at https://github.com/python/pymanager.", key_path) - resolve_config(cfg, key_path, _global_file().parent, schema=schema, error_unknown=True) + + try: + from _native import package_get_root + root = Path(package_get_root()) + except ImportError: + root = Path(sys.executable).parent + resolve_config(cfg, key_path, root, schema=schema, error_unknown=True) return cfg diff --git a/src/manage/install_command.py b/src/manage/install_command.py index 5dea298..0462d93 100644 --- a/src/manage/install_command.py +++ b/src/manage/install_command.py @@ -10,7 +10,7 @@ ) from .fsutils import ensure_tree, rmtree, unlink from .indexutils import Index -from .logging import CONSOLE_MAX_WIDTH, LOGGER, ProgressPrinter +from .logging import CONSOLE_MAX_WIDTH, LOGGER, ProgressPrinter, VERBOSE from .pathutils import Path, PurePath from .tagutils import install_matches_any, tag_or_range from .urlutils import ( @@ -286,7 +286,7 @@ def _cleanup_arp_entries(cmd, install_shortcut_pairs): } -def update_all_shortcuts(cmd, path_warning=True): +def update_all_shortcuts(cmd): LOGGER.debug("Updating global shortcuts") alias_written = set() shortcut_written = {} @@ -329,34 +329,48 @@ def update_all_shortcuts(cmd, path_warning=True): for k, (_, cleanup) in SHORTCUT_HANDLERS.items(): cleanup(cmd, shortcut_written.get(k, [])) - if path_warning and cmd.global_dir and cmd.global_dir.is_dir() and any(cmd.global_dir.glob("*.exe")): + +def print_cli_shortcuts(cmd): + if cmd.global_dir and cmd.global_dir.is_dir() and any(cmd.global_dir.glob("*.exe")): try: if not any(cmd.global_dir.match(p) for p in os.getenv("PATH", "").split(os.pathsep) if p): LOGGER.info("") LOGGER.info("!B!Global shortcuts directory is not on PATH. " + - "Add it for easy access to global Python commands.!W!") + "Add it for easy access to global Python aliases.!W!") LOGGER.info("!B!Directory to add: !Y!%s!W!", cmd.global_dir) LOGGER.info("") + return except Exception: LOGGER.debug("Failed to display PATH warning", exc_info=True) + return - -def print_cli_shortcuts(cmd): + from .installs import get_install_alias_names installs = cmd.get_installs() - seen = set() + tags = getattr(cmd, "tags", None) + seen = {"python.exe".casefold()} + verbose = LOGGER.would_log_to_console(VERBOSE) for i in installs: - aliases = sorted(a["name"] for a in i["alias"] if a["name"].casefold() not in seen) - seen.update(n.casefold() for n in aliases) - if not install_matches_any(i, cmd.tags): + # We need to pre-filter aliases before getting the nice names. + aliases = [a for a in i.get("alias", ()) if a["name"].casefold() not in seen] + seen.update(n["name"].casefold() for n in aliases) + if not verbose: + if i.get("default"): + LOGGER.debug("%s will be launched by !G!python.exe!W!", i["display-name"]) + names = get_install_alias_names(aliases, windowed=True) + LOGGER.debug("%s will be launched by %s", i["display-name"], ", ".join(names)) + + if tags and not install_matches_any(i, cmd.tags): continue - if i.get("default") and aliases: + + names = get_install_alias_names(aliases, windowed=False) + if i.get("default") and names: LOGGER.info("%s will be launched by !G!python.exe!W! and also %s", - i["display-name"], ", ".join(aliases)) + i["display-name"], ", ".join(names)) elif i.get("default"): LOGGER.info("%s will be launched by !G!python.exe!W!.", i["display-name"]) - elif aliases: + elif names: LOGGER.info("%s will be launched by %s", - i["display-name"], ", ".join(aliases)) + i["display-name"], ", ".join(names)) else: LOGGER.info("Installed %s to %s", i["display-name"], i["prefix"]) @@ -545,6 +559,7 @@ def execute(cmd): else: LOGGER.info("Refreshing install registrations.") update_all_shortcuts(cmd) + print_cli_shortcuts(cmd) LOGGER.debug("END install_command.execute") return diff --git a/src/manage/installs.py b/src/manage/installs.py index 569ce1b..4b1dfa5 100644 --- a/src/manage/installs.py +++ b/src/manage/installs.py @@ -3,7 +3,7 @@ from .exceptions import NoInstallFoundError, NoInstallsError from .logging import DEBUG, LOGGER from .pathutils import Path -from .tagutils import CompanyTag, tag_or_range, companies_match +from .tagutils import CompanyTag, tag_or_range, companies_match, split_platform from .verutils import Version @@ -120,6 +120,79 @@ def get_installs( return installs +def _make_alias_key(alias): + n1, sep, n3 = alias.rpartition(".") + n2 = "" + n3 = sep + n3 + + n1, plat = split_platform(n1) + + while n1 and n1[-1] in "0123456789.-": + n2 = n1[-1] + n2 + n1 = n1[:-1] + + if n1 and n1[-1].casefold() == "w".casefold(): + w = "w" + n1 = n1[:-1] + else: + w = "" + + return n1, w, n2, plat, n3 + + +def _make_opt_part(parts): + if not parts: + return "" + if len(parts) == 1: + return list(parts)[0] + return "[{}]".format("|".join(sorted(p for p in parts if p))) + + +def _sk_sub(m): + n = m.group(1) + if not n: + return "" + if n in "[]": + return "" + try: + return f"{int(n):020}" + except ValueError: + pass + return n + + +def _make_alias_name_sortkey(n): + import re + return re.sub(r"(\d+|\[|\])", _sk_sub, n) + + +def get_install_alias_names(aliases, friendly=True, windowed=True): + if not windowed: + aliases = [a for a in aliases if not a.get("windowed")] + if not friendly: + return sorted(a["name"] for a in aliases) + + seen = {} + has_w = {} + plats = {} + for n1, w, n2, plat, n3 in (_make_alias_key(a["name"]) for a in aliases): + k = n1.casefold(), n2.casefold(), n3.casefold() + seen.setdefault(k, (n1, n2, n3)) + has_w.setdefault(k, set()).add(w) + plats.setdefault(k, set()).add(plat) + + result = [] + for k, (n1, n2, n3) in seen.items(): + result.append("".join([ + n1, + _make_opt_part(has_w.get(k)), + n2, + _make_opt_part(plats.get(k)), + n3, + ])) + return sorted(result, key=_make_alias_name_sortkey) + + def _patch_install_to_run(i, run_for): return { **i, diff --git a/src/manage/list_command.py b/src/manage/list_command.py index 46d78f0..fde28bb 100644 --- a/src/manage/list_command.py +++ b/src/manage/list_command.py @@ -7,43 +7,14 @@ LOGGER = logging.LOGGER -def _exe_partition(n): - n1, sep, n2 = n.rpartition(".") - n2 = sep + n2 - while n1 and n1[-1] in "0123456789.-": - n2 = n1[-1] + n2 - n1 = n1[:-1] - w = "" - if n1 and n1[-1] == "w": - w = "w" - n1 = n1[:-1] - return n1, w, n2 - - def _format_alias(i, seen): - try: - alias = i["alias"] - except KeyError: - return "" - if not alias: - return "" - if len(alias) == 1: - a = i["alias"][0] - n = a["name"].casefold() - if n in seen: - return "" - seen.add(n) - return i["alias"][0]["name"] - names = {_exe_partition(a["name"].casefold()): a["name"] for a in alias - if a["name"].casefold() not in seen} - seen.update(a["name"].casefold() for a in alias) - for n1, w, n2 in list(names): - k = (n1, "", n2) - if w and k in names: - del names[n1, w, n2] - n1, _, n2 = _exe_partition(names[k]) - names[k] = f"{n1}[w]{n2}" - return ", ".join(names[n] for n in sorted(names)) + from manage.installs import get_install_alias_names + aliases = [a for a in i.get("alias", ()) if a["name"].casefold() not in seen] + seen.update(a["name"].casefold() for a in aliases) + + include_w = LOGGER.would_log_to_console(logging.VERBOSE) + names = get_install_alias_names(aliases, windowed=include_w) + return ", ".join(names) def _format_tag_with_co(cmd, i): diff --git a/src/manage/tagutils.py b/src/manage/tagutils.py index cf00fd2..e2a22cd 100644 --- a/src/manage/tagutils.py +++ b/src/manage/tagutils.py @@ -123,7 +123,7 @@ def __lt__(self, other): return self.sortkey > other.sortkey -def _split_platform(tag): +def split_platform(tag): if tag.endswith(SUPPORTED_PLATFORM_SUFFIXES): for t in SUPPORTED_PLATFORM_SUFFIXES: if tag.endswith(t): @@ -178,7 +178,7 @@ def __init__(self, company_or_tag, tag=None, *, loose_company=True): else: assert isinstance(company_or_tag, _CompanyKey) self._company = company_or_tag - self.tag, self.platform = _split_platform(tag) + self.tag, self.platform = split_platform(tag) self._sortkey = _sort_tag(self.tag) @property diff --git a/src/manage/uninstall_command.py b/src/manage/uninstall_command.py index 6661112..da117b7 100644 --- a/src/manage/uninstall_command.py +++ b/src/manage/uninstall_command.py @@ -127,6 +127,6 @@ def execute(cmd): LOGGER.debug("TRACEBACK:", exc_info=True) if to_uninstall: - update_all_shortcuts(cmd, path_warning=False) + update_all_shortcuts(cmd) LOGGER.debug("END uninstall_command.execute") diff --git a/tests/conftest.py b/tests/conftest.py index 68fc24c..200be10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -201,7 +201,7 @@ def make_install(tag, **kwargs): run_for.append({"tag": t, "target": kwargs.get("target", "python.exe")}) run_for.append({"tag": t, "target": kwargs.get("targetw", "pythonw.exe"), "windowed": 1}) - return { + i = { "company": kwargs.get("company", "PythonCore"), "id": "{}-{}".format(kwargs.get("company", "PythonCore"), tag), "sort-version": kwargs.get("sort_version", tag), @@ -212,12 +212,17 @@ def make_install(tag, **kwargs): "prefix": PurePath(kwargs.get("prefix", rf"C:\{tag}")), "executable": kwargs.get("executable", "python.exe"), } + try: + i["alias"] = kwargs["alias"] + except LookupError: + pass + return i def fake_get_installs(install_dir): yield make_install("1.0") - yield make_install("1.0-32", sort_version="1.0") - yield make_install("1.0-64", sort_version="1.0") + yield make_install("1.0-32", sort_version="1.0", alias=[dict(name="py1.0.exe"), dict(name="py1.0-32.exe")]) + yield make_install("1.0-64", sort_version="1.0", alias=[dict(name="py1.0.exe"), dict(name="py1.0-64.exe")]) yield make_install("2.0-64", sort_version="2.0") yield make_install("2.0-arm64", sort_version="2.0") yield make_install("3.0a1-32", sort_version="3.0a1") diff --git a/tests/test_install_command.py b/tests/test_install_command.py index e0cc129..0e15dfd 100644 --- a/tests/test_install_command.py +++ b/tests/test_install_command.py @@ -1,6 +1,10 @@ +import os import pytest import secrets +from pathlib import Path, PurePath + from manage import install_command as IC +from manage import installs @pytest.fixture @@ -8,6 +12,7 @@ def alias_checker(tmp_path): with AliasChecker(tmp_path) as checker: yield checker + class AliasChecker: class Cmd: global_dir = "out" @@ -95,3 +100,34 @@ def test_write_alias_default_platform(alias_checker): 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_print_cli_shortcuts(patched_installs, assert_log, monkeypatch, tmp_path): + class Cmd: + global_dir = Path(tmp_path) + def get_installs(self): + return installs.get_installs(None) + + (tmp_path / "fake.exe").write_bytes(b"") + + monkeypatch.setitem(os.environ, "PATH", f"{os.environ['PATH']};{Cmd.global_dir}") + IC.print_cli_shortcuts(Cmd()) + assert_log( + assert_log.skip_until("Installed %s", ["Python 2.0-64", PurePath("C:\\2.0-64")]), + assert_log.skip_until("%s will be launched by %s", ["Python 1.0-64", "py1.0[-64].exe"]), + ("%s will be launched by %s", ["Python 1.0-32", "py1.0-32.exe"]), + ) + + +def test_print_path_warning(patched_installs, assert_log, tmp_path): + class Cmd: + global_dir = Path(tmp_path) + def get_installs(self): + return installs.get_installs(None) + + (tmp_path / "fake.exe").write_bytes(b"") + + IC.print_cli_shortcuts(Cmd()) + assert_log( + assert_log.skip_until(".*Global shortcuts directory is not on PATH") + ) diff --git a/tests/test_installs.py b/tests/test_installs.py index 4a607ef..a9ffc3f 100644 --- a/tests/test_installs.py +++ b/tests/test_installs.py @@ -126,3 +126,34 @@ def test_get_install_to_run_with_range(patched_installs): i = installs.get_install_to_run("", None, ">1.0") assert i["id"] == "PythonCore-2.0-64" assert i["executable"].match("python.exe") + + +def test_install_alias_make_alias_sortkey(): + assert ("pythonw00000000000000000003-00000000000000000064.exe" + == installs._make_alias_name_sortkey("pythonw3-64.exe")) + assert ("pythonw00000000000000000003-00000000000000000064.exe" + == installs._make_alias_name_sortkey("python[w]3[-64].exe")) + +def test_install_alias_make_alias_key(): + assert ("python", "w", "3", "-64", ".exe") == installs._make_alias_key("pythonw3-64.exe") + assert ("python", "w", "3", "", ".exe") == installs._make_alias_key("pythonw3.exe") + assert ("pythonw3-xyz", "", "", "", ".exe") == installs._make_alias_key("pythonw3-xyz.exe") + assert ("python", "", "3", "-64", ".exe") == installs._make_alias_key("python3-64.exe") + assert ("python", "", "3", "", ".exe") == installs._make_alias_key("python3.exe") + assert ("python3-xyz", "", "", "", ".exe") == installs._make_alias_key("python3-xyz.exe") + + +def test_install_alias_opt_part(): + assert "" == installs._make_opt_part([]) + assert "x" == installs._make_opt_part(["x"]) + assert "[x]" == installs._make_opt_part(["x", ""]) + assert "[x|y]" == installs._make_opt_part(["", "y", "x"]) + + +def test_install_alias_names(): + input = [{"name": i} for i in ["py3.exe", "PY3-64.exe", "PYW3.exe", "pyw3-64.exe"]] + input.extend([{"name": i, "windowed": 1} for i in ["xy3.exe", "XY3-64.exe", "XYW3.exe", "xyw3-64.exe"]]) + expect = ["py[w]3[-64].exe"] + expectw = ["py[w]3[-64].exe", "xy[w]3[-64].exe"] + assert expect == installs.get_install_alias_names(input, friendly=True, windowed=False) + assert expectw == installs.get_install_alias_names(input, friendly=True, windowed=True)