diff --git a/winpython/associate.py b/winpython/associate.py index 1d2796c1..673baf3d 100644 --- a/winpython/associate.py +++ b/winpython/associate.py @@ -8,32 +8,21 @@ import sys import os from pathlib import Path -import platform import importlib.util import winreg from winpython import utils from argparse import ArgumentParser -# --- Constants --- -KEY_C = r"Software\Classes\%s" -KEY_CP = r"Software\Classes" -KEY_S = r"Software\Python" -KEY_S0 = KEY_S + r"\WinPython" # was PythonCore before PEP-0514 -EWI = "Edit with IDLE" -EWS = "Edit with Spyder" -DROP_HANDLER_CLSID = "{60254CA5-953B-11CF-8C96-00AA00B8708C}" # --- Helper functions for Registry --- def _set_reg_value(root, key_path, name, value, reg_type=winreg.REG_SZ, verbose=False): """Helper to create key and set a registry value using CreateKeyEx.""" rootkey_name = "HKEY_CURRENT_USER" if root == winreg.HKEY_CURRENT_USER else "HKEY_LOCAL_MACHINE" + if verbose: + print(f"{rootkey_name}\\{key_path}\\{name if name else ''}:{value}") try: # Use CreateKeyEx with context manager for automatic closing - # KEY_WRITE access is needed to set values - - if verbose: - print(f"{rootkey_name}\\{key_path}\\{name if name else ''}:{value}") with winreg.CreateKeyEx(root, key_path, 0, winreg.KEY_WRITE) as key: winreg.SetValueEx(key, name, 0, reg_type, value) except OSError as e: @@ -42,9 +31,9 @@ def _set_reg_value(root, key_path, name, value, reg_type=winreg.REG_SZ, verbose= def _delete_reg_key(root, key_path, verbose=False): """Helper to delete a registry key, ignoring if not found.""" rootkey_name = "HKEY_CURRENT_USER" if root == winreg.HKEY_CURRENT_USER else "HKEY_LOCAL_MACHINE" + if verbose: + print(f"{rootkey_name}\\{key_path}") try: - if verbose: - print(f"{rootkey_name}\\{key_path}") # DeleteKey can only delete keys with no subkeys. # For keys with (still) subkeys, use DeleteKeyEx on the parent key if available winreg.DeleteKey(root, key_path) @@ -79,7 +68,6 @@ def _get_shortcut_data(target, current=True, has_pywin32=False): bname, ext = Path(name).stem, Path(name).suffix if ext.lower() == ".exe": # Path for the shortcut file in the start menu folder - # This depends on utils.create_winpython_start_menu_folder creating the right path shortcut_name = str(Path(utils.create_winpython_start_menu_folder(current=current)) / bname) + '.lnk' data.append( ( @@ -90,128 +78,86 @@ def _get_shortcut_data(target, current=True, has_pywin32=False): ) return data -# --- Registry Entry Definitions --- - -# Structure: (key_path, value_name, value, reg_type) -# Use None for value_name to set the default value of the key -REGISTRY_ENTRIES = [] - -# --- Extensions --- -EXTENSIONS = { - ".py": "Python.File", - ".pyw": "Python.NoConFile", - ".pyc": "Python.CompiledFile", - ".pyo": "Python.CompiledFile", -} -for ext, file_type in EXTENSIONS.items(): - REGISTRY_ENTRIES.append((KEY_C % ext, None, file_type)) - -# --- MIME types --- -MIME_TYPES = { - ".py": "text/plain", - ".pyw": "text/plain", -} -for ext, mime_type in MIME_TYPES.items(): - REGISTRY_ENTRIES.append((KEY_C % ext, "Content Type", mime_type)) - -# --- Verbs (Open, Edit with IDLE, Edit with Spyder) --- -# These depend on the python/pythonw/spyder paths -def _get_verb_entries(target): - python = str((Path(target) / "python.exe").resolve()) - pythonw = str((Path(target) / "pythonw.exe").resolve()) - spyder_exe = str((Path(target).parent / "Spyder.exe").resolve()) - - # Command string for Spyder, fallback to script if exe not found - spyder_cmd = rf'"{spyder_exe}" "%1"' if Path(spyder_exe).is_file() else rf'"{pythonw}" "{target}\Scripts\spyder" "%1"' - - verbs_data = [ - # Open verb - (rf"{KEY_CP}\Python.File\shell\open\command", None, rf'"{python}" "%1" %*'), - (rf"{KEY_CP}\Python.NoConFile\shell\open\command", None, rf'"{pythonw}" "%1" %*'), - (rf"{KEY_CP}\Python.CompiledFile\shell\open\command", None, rf'"{python}" "%1" %*'), - # Edit with IDLE verb - (rf"{KEY_CP}\Python.File\shell\{EWI}\command", None, rf'"{pythonw}" "{target}\Lib\idlelib\idle.pyw" -n -e "%1"'), - (rf"{KEY_CP}\Python.NoConFile\shell\{EWI}\command", None, rf'"{pythonw}" "{target}\Lib\idlelib\idle.pyw" -n -e "%1"'), - # Edit with Spyder verb - (rf"{KEY_CP}\Python.File\shell\{EWS}\command", None, spyder_cmd), - (rf"{KEY_CP}\Python.NoConFile\shell\{EWS}\command", None, spyder_cmd), - ] - return verbs_data - -# --- Drop support --- -DROP_SUPPORT_FILE_TYPES = ["Python.File", "Python.NoConFile", "Python.CompiledFile"] -for file_type in DROP_SUPPORT_FILE_TYPES: - REGISTRY_ENTRIES.append((rf"{KEY_C % file_type}\shellex\DropHandler", None, DROP_HANDLER_CLSID)) - -# --- Icons --- -def _get_icon_entries(target): - dlls_path = str(Path(target) / "DLLs") - icon_data = [ - (rf"{KEY_CP}\Python.File\DefaultIcon", None, rf"{dlls_path}\py.ico"), - (rf"{KEY_CP}\Python.NoConFile\DefaultIcon", None, rf"{dlls_path}\py.ico"), - (rf"{KEY_CP}\Python.CompiledFile\DefaultIcon", None, rf"{dlls_path}\pyc.ico"), - ] - return icon_data - -# --- Descriptions --- -DESCRIPTIONS = { - "Python.File": "Python File", - "Python.NoConFile": "Python File (no console)", - "Python.CompiledFile": "Compiled Python File", -} -for file_type, desc in DESCRIPTIONS.items(): - REGISTRY_ENTRIES.append((KEY_C % file_type, None, desc)) - - # --- PythonCore entries (PEP-0514 and WinPython specific) --- -def _get_pythoncore_entries(target): - python_infos = utils.get_python_infos(target) # ('3.11', 64) - short_version = python_infos[0] # e.g., '3.11' - long_version = utils.get_python_long_version(target) # e.g., '3.11.5' - - SupportUrl = "https://winpython.github.io" - SysArchitecture = f'{python_infos[1]}bit' # e.g., '64bit' - SysVersion = short_version # e.g., '3.11' - Version = long_version # e.g., '3.11.5' - DisplayName = f'Python {Version} ({SysArchitecture})' - - python_exe = str((Path(target) / "python.exe").resolve()) - pythonw_exe = str((Path(target) / "pythonw.exe").resolve()) - - core_entries = [] - - # Main version key (WinPython\3.11) - version_key = f"{KEY_S0}\\{short_version}" - core_entries.extend([ - (version_key, 'DisplayName', DisplayName), - (version_key, 'SupportUrl', SupportUrl), - (version_key, 'SysVersion', SysVersion), - (version_key, 'SysArchitecture', SysArchitecture), - (version_key, 'Version', Version), - ]) - # InstallPath key (WinPython\3.11\InstallPath) - install_path_key = f"{version_key}\\InstallPath" - core_entries.extend([ - (install_path_key, None, str(Path(target) / '')), # Default value is the install dir - (install_path_key, 'ExecutablePath', python_exe), - (install_path_key, 'WindowedExecutablePath', pythonw_exe), - ]) - # InstallGroup key (WinPython\3.11\InstallPath\InstallGroup) - core_entries.append((f"{install_path_key}\\InstallGroup", None, f"Python {short_version}")) +def register_in_registery(target, current=True, reg_type=winreg.REG_SZ, verbose=True) -> tuple[list[any], ...]: + """Register in Windows (like regedit)""" - # Modules key (WinPython\3.11\Modules) - seems to be a placeholder key - core_entries.append((f"{version_key}\\Modules", None, "")) - - # PythonPath key (WinPython\3.11\PythonPath) - core_entries.append((f"{version_key}\\PythonPath", None, rf"{target}\Lib;{target}\DLLs")) - - # Help key (WinPython\3.11\Help\Main Python Documentation) - core_entries.append((f"{version_key}\\Help\\Main Python Documentation", None, rf"{target}\Doc\python{long_version}.chm")) - - return core_entries + # --- Constants --- + DROP_HANDLER_CLSID = "{60254CA5-953B-11CF-8C96-00AA00B8708C}" + # --- CONFIG --- + target_path = Path(target).resolve() + python_exe = str(target_path / "python.exe") + pythonw_exe = str(target_path / "pythonw.exe") + spyder_exe = str(target_path.parent / "Spyder.exe") + icon_py = str(target / "DLLs" / "py.ico") + icon_pyc = str(target / "DLLs" / "pyc.ico") + idle_path = str(target / "Lib" / "idlelib" / "idle.pyw") + doc_path = str(target / "Doc" / "html" / "index.html") + python_infos = utils.get_python_infos(target) # ('3.11', 64) + short_version = python_infos[0] # e.g., '3.11' + version = utils.get_python_long_version(target) # e.g., '3.11.5' + arch = f'{python_infos[1]}bit' # e.g., '64bit' + display = f"Python {version} ({arch})" + + permanent_entries = [] # key_path, name, value + dynamic_entries = [] # key_path, name, value + core_entries = [] # key_path, name, value + lost_entries = [] # intermediate keys to remove later + # --- File associations --- + ext_map = {".py": "Python.File", ".pyw": "Python.NoConFile", ".pyc": "Python.CompiledFile"} + ext_label = {".py": "Python File", ".pyw": "Python File (no console)", ".pyc": "Compiled Python File"} + for ext, ftype in ext_map.items(): + permanent_entries.append((f"Software\\Classes\\{ext}", None, ftype)) + if ext in (".py", ".pyw"): + permanent_entries.append((f"Software\\Classes\\{ext}", "Content Type", "text/plain")) + + # --- Descriptions, Icons, DropHandlers --- + for ext, ftype in ext_map.items(): + dynamic_entries.append((f"Software\\Classes\\{ftype}", None, ext_label[ext])) + dynamic_entries.append((f"Software\\Classes\\{ftype}\\DefaultIcon", None, icon_py if "Compiled" not in ftype else icon_pyc)) + dynamic_entries.append((f"Software\\Classes\\{ftype}\\shellex\\DropHandler", None, DROP_HANDLER_CLSID)) + lost_entries.append((f"Software\\Classes\\{ftype}\\shellex", None, None)) + + # --- Shell commands --- + for ext, ftype in ext_map.items(): + dynamic_entries.append((f"Software\\Classes\\{ftype}\\shell\\open\\command", None, f'"{pythonw_exe if ftype=='Python.NoConFile' else python_exe} if " "%1" %*')) + lost_entries.append((f"Software\\Classes\\{ftype}\\shell\\open", None, None)) + lost_entries.append((f"Software\\Classes\\{ftype}\\shell", None, None)) + + dynamic_entries.append((rf"Software\Classes\Python.File\shell\Edit with IDLE\command", None, f'"{pythonw_exe}" "{idle_path}" -n -e "%1"')) + dynamic_entries.append((rf"Software\Classes\Python.NoConFile\shell\Edit with IDLE\command", None, f'"{pythonw_exe}" "{idle_path}" -n -e "%1"')) + lost_entries.append((rf"Software\Classes\Python.File\shell\Edit with IDLE", None, None)) + lost_entries.append((rf"Software\Classes\Python.NoConFile\shell\Edit with IDLE", None, None)) + + if Path(spyder_exe).exists(): + dynamic_entries.append((rf"Software\Classes\Python.File\shell\Edit with Spyder\command", None, f'"{spyder_exe}" "%1"')) + dynamic_entries.append((rf"Software\Classes\Python.NoConFile\shell\Edit with Spyder\command", None, f'"{spyder_exe}" "%1"')) + lost_entries.append((rf"Software\Classes\Python.File\shell\Edit with Spyder", None, None)) + lost_entries.append((rf"Software\Classes\Python.NoConFile\shell\Edit with Spyder", None, None)) + + # --- WinPython Core registry entries (PEP 514 style) --- + base = f"Software\\Python\\WinPython\\{short_version}" + core_entries.append((base, "DisplayName", display)) + core_entries.append((base, "SupportUrl", "https://winpython.github.io")) + core_entries.append((base, "SysVersion", short_version)) + core_entries.append((base, "SysArchitecture", arch)) + core_entries.append((base, "Version", version)) + + core_entries.append((f"{base}\\InstallPath", None, str(target))) + core_entries.append((f"{base}\\InstallPath", "ExecutablePath", python_exe)) + core_entries.append((f"{base}\\InstallPath", "WindowedExecutablePath", pythonw_exe)) + core_entries.append((f"{base}\\InstallPath\\InstallGroup", None, f"Python {short_version}")) + + core_entries.append((f"{base}\\Modules", None, "")) + core_entries.append((f"{base}\\PythonPath", None, f"{target}\\Lib;{target}\\DLLs")) + core_entries.append((f"{base}\\Help\\Main Python Documentation", None, doc_path)) + lost_entries.append((f"{base}\\Help", None, None)) + lost_entries.append((f"Software\\Python\\WinPython", None, None)) + + return permanent_entries, dynamic_entries, core_entries, lost_entries # --- Main Register/Unregister Functions --- @@ -223,19 +169,11 @@ def register(target, current=True, reg_type=winreg.REG_SZ, verbose=True): if verbose: print(f'Creating WinPython registry entries for {target}') - # Set static registry entries - for key_path, name, value in REGISTRY_ENTRIES: - _set_reg_value(root, key_path, name, value, verbose=verbose) - - # Set dynamic registry entries (verbs, icons, pythoncore) - dynamic_entries = [] - dynamic_entries.extend(_get_verb_entries(target)) - dynamic_entries.extend(_get_icon_entries(target)) - dynamic_entries.extend(_get_pythoncore_entries(target)) - - for key_path, name, value in dynamic_entries: - _set_reg_value(root, key_path, name, value) - + permanent_entries, dynamic_entries, core_entries, lost_entries = register_in_registery(target) + # Set registry entries for given target + for key_path, name, value in permanent_entries + dynamic_entries + core_entries: + _set_reg_value(root, key_path, name, value, verbose=verbose) + # Create start menu entries if has_pywin32: if verbose: @@ -246,8 +184,7 @@ def register(target, current=True, reg_type=winreg.REG_SZ, verbose=True): except Exception as e: print(f"Error creating shortcut for {desc} at {fname}: {e}", file=sys.stderr) else: - print("Skipping start menu shortcut creation as pywin32 package is needed.") - + print("Skipping start menu shortcut creation as pywin32 package is needed.") def unregister(target, current=True, verbose=True): """Unregister a Python distribution from Windows registry and remove Start Menu shortcuts""" @@ -256,81 +193,15 @@ def unregister(target, current=True, verbose=True): if verbose: print(f'Removing WinPython registry entries for {target}') + + permanent_entries, dynamic_entries, core_entries , lost_entries = register_in_registery(target) # List of keys to attempt to delete, ordered from most specific to general - keys_to_delete = [] - - # Add dynamic keys first (helps DeleteKey succeed) - dynamic_entries = [] - dynamic_entries.extend(_get_verb_entries(target)) - dynamic_entries.extend(_get_icon_entries(target)) - dynamic_entries.extend(_get_pythoncore_entries(target)) - - # Collect parent keys from dynamic entries - dynamic_parent_keys = {entry[0] for entry in dynamic_entries} - # Add keys from static entries - static_parent_keys = {entry[0] for entry in REGISTRY_ENTRIES} - - # Combine and add the key templates that might become empty and should be removed - python_infos = utils.get_python_infos(target) - short_version = python_infos[0] - version_key_base = f"{KEY_S0}\\{short_version}" - - # Keys from static REGISTRY_ENTRIES (mostly Class registrations) - keys_to_delete.extend([ - KEY_C % file_type + rf"\shellex\DropHandler" for file_type in DROP_SUPPORT_FILE_TYPES - ]) - keys_to_delete.extend([ - KEY_C % file_type + rf"\shellex" for file_type in DROP_SUPPORT_FILE_TYPES - ]) - #keys_to_delete.extend([ - # KEY_C % file_type + rf"\DefaultIcon" for file_type in set(EXTENSIONS.values()) # Use values as file types - #]) - keys_to_delete.extend([ - KEY_C % file_type + rf"\shell\{EWI}\command" for file_type in ["Python.File", "Python.NoConFile"] # Specific types for IDLE verb - ]) - keys_to_delete.extend([ - KEY_C % file_type + rf"\shell\{EWS}\command" for file_type in ["Python.File", "Python.NoConFile"] # Specific types for Spyder verb - ]) - # General open command keys (cover all file types) - keys_to_delete.extend([ - KEY_C % file_type + rf"\shell\open\command" for file_type in ["Python.File", "Python.NoConFile", "Python.CompiledFile"] - ]) - - - # Keys from dynamic entries (Verbs, Icons, PythonCore) - add parents - # Verbs - keys_to_delete.extend([KEY_C % file_type + rf"\shell\{EWI}" for file_type in ["Python.File", "Python.NoConFile"]]) - keys_to_delete.extend([KEY_C % file_type + rf"\shell\{EWS}" for file_type in ["Python.File", "Python.NoConFile"]]) - keys_to_delete.extend([KEY_C % file_type + rf"\shell\open" for file_type in ["Python.File", "Python.NoConFile", "Python.CompiledFile"]]) - keys_to_delete.extend([KEY_C % file_type + rf"\shell" for file_type in ["Python.File", "Python.NoConFile", "Python.CompiledFile"]]) # Shell parent - - # Icons - keys_to_delete.extend([KEY_C % file_type + rf"\DefaultIcon" for file_type in set(EXTENSIONS.values())]) # Already added above? Check for duplicates or order - keys_to_delete.extend([KEY_C % file_type for file_type in set(EXTENSIONS.values())]) # Parent keys for file types - - # Extensions/Descriptions parents - # keys_to_delete.extend([KEY_C % ext for ext in EXTENSIONS.keys()]) # e.g., .py, .pyw - - # PythonCore keys (from most specific down to the base) - keys_to_delete.extend([ - f"{version_key_base}\\InstallPath\\InstallGroup", - f"{version_key_base}\\InstallPath", - f"{version_key_base}\\Modules", - f"{version_key_base}\\PythonPath", - f"{version_key_base}\\Help\\Main Python Documentation", - f"{version_key_base}\\Help", - version_key_base, # e.g., Software\Python\WinPython\3.11 - KEY_S0, # Software\Python\WinPython - #KEY_S, # Software\Python (only if WinPython key is the only subkey - risky to delete) - ]) - - # Attempt to delete keys - # Use a set to avoid duplicates, then sort by length descending to try deleting children first - # (although DeleteKey only works on empty keys anyway, so explicit ordering is clearer) - - for key in keys_to_delete: - _delete_reg_key(root, key, verbose=verbose) + keys_to_delete = sorted(list(set(key_path for key_path , name, value in (dynamic_entries + core_entries + lost_entries))), key=len, reverse=True) + + rootkey_name = "HKEY_CURRENT_USER" if root == winreg.HKEY_CURRENT_USER else "HKEY_LOCAL_MACHINE" + for key_path in keys_to_delete: + _delete_reg_key(root, key_path, verbose=verbose) # Remove start menu shortcuts if has_pywin32: @@ -338,10 +209,10 @@ def unregister(target, current=True, verbose=True): print(f'Removing WinPython menu for all icons in {target.parent}') _remove_start_menu_folder(target, current=current, has_pywin32=True) # The original code had commented out code to delete .lnk files individually. - # remove_winpython_start_menu_folder is likely the intended method. else: print("Skipping start menu removal as pywin32 package is needed.") + if __name__ == "__main__": # Ensure we are running from the target WinPython environment parser = ArgumentParser(description="Register or Un-register Python file extensions, icons "\