From 9f94cda3aa00e86f2643a073215a1e8e30d33e31 Mon Sep 17 00:00:00 2001 From: stonebig Date: Sat, 28 Jun 2025 17:19:44 +0200 Subject: [PATCH] make pseudo-wheel part of winpython a true wheel 'wppm' looks the needed direction towards pure pylock.toml --- make.py | 10 +- ...directory_and_userprofile_be_winpython.bat | 4 +- ...ake_working_directory_be_not_winpython.bat | 4 +- .../make_working_directory_be_winpython.bat | 2 +- portable/scripts/upgrade_pip.bat | 2 +- pyproject.toml | 8 +- {winpython => wppm}/__init__.py | 66 +- {winpython => wppm}/associate.py | 600 ++++++------- {winpython => wppm}/diff.py | 0 {winpython => wppm}/hash.py | 0 {winpython => wppm}/packagemetadata.py | 0 {winpython => wppm}/piptree.py | 0 {winpython => wppm}/utils.py | 626 +++++++------- {winpython => wppm}/wheelhouse.py | 0 {winpython => wppm}/wppm.py | 810 +++++++++--------- 15 files changed, 1066 insertions(+), 1066 deletions(-) rename {winpython => wppm}/__init__.py (95%) rename {winpython => wppm}/associate.py (97%) rename {winpython => wppm}/diff.py (100%) rename {winpython => wppm}/hash.py (100%) rename {winpython => wppm}/packagemetadata.py (100%) rename {winpython => wppm}/piptree.py (100%) rename {winpython => wppm}/utils.py (97%) rename {winpython => wppm}/wheelhouse.py (100%) rename {winpython => wppm}/wppm.py (98%) diff --git a/make.py b/make.py index ec75c078..651cb458 100644 --- a/make.py +++ b/make.py @@ -12,7 +12,7 @@ import subprocess import sys from pathlib import Path -from winpython import wppm, utils, diff +from wppm import wppm, utils, diff # Define constant paths for clarity CHANGELOGS_DIRECTORY = Path(__file__).parent / "changelogs" @@ -222,7 +222,7 @@ def build(self, rebuild: bool = True, requirements_files_list=None, winpy_dirnam utils.python_execmodule("ensurepip", self.distribution.target) self.distribution.patch_standard_packages("pip") - essential_packages = ["pip", "setuptools", "wheel", "winpython"] + essential_packages = ["pip", "setuptools", "wheel", "wppm"] for package_name in essential_packages: actions = ["install", "--upgrade", "--pre", package_name] + self.install_options self._print_action(f"Piping: {' '.join(actions)}") @@ -247,8 +247,8 @@ def build(self, rebuild: bool = True, requirements_files_list=None, winpy_dirnam diff.write_changelog(self.winpyver2, None, CHANGELOGS_DIRECTORY, self.flavor, self.distribution.architecture, basedir=self.winpython_directory.parent) def rebuild_winpython_package(source_directory: Path, target_directory: Path, architecture: int = 64, verbose: bool = False): - """Rebuilds the winpython package from source using flit.""" - for file in target_directory.glob("winpython-*"): + """Rebuilds the winpython or wppm package from source using flit.""" + for file in target_directory.glob("w*p*-*.*"): if file.suffix in (".exe", ".whl", ".gz"): file.unlink() utils.buildflit_wininst(source_directory, copy_to=target_directory, verbose=True) @@ -295,7 +295,7 @@ def make_all(build_number: int, release_level: str, pyver: str, architecture: in os.makedirs(build_directory, exist_ok=True) # use source_dirs as the directory to re-build Winpython wheel winpython_source_dir = Path(__file__).resolve().parent - rebuild_winpython_package(winpython_source_dir, Path(source_dirs), architecture, verbose) + # 2025-06-28 no more: rebuild_winpython_package(winpython_source_dir, Path(source_dirs), architecture, verbose) builder = WinPythonDistributionBuilder( build_number, release_level, build_directory, wheels_directory=source_dirs, diff --git a/portable/scripts/make_working_directory_and_userprofile_be_winpython.bat b/portable/scripts/make_working_directory_and_userprofile_be_winpython.bat index 2806dc14..489aef0b 100644 --- a/portable/scripts/make_working_directory_and_userprofile_be_winpython.bat +++ b/portable/scripts/make_working_directory_and_userprofile_be_winpython.bat @@ -1,3 +1,3 @@ call "%~dp0env_for_icons.bat" -"%PYTHON%" -c "from winpython.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[active_environment', '[inactive_environment' )" -"%PYTHON%" -c "from winpython.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[inactive_environment_common]', '[active_environment_common]' )" \ No newline at end of file +"%PYTHON%" -c "from wppm.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[active_environment', '[inactive_environment' )" +"%PYTHON%" -c "from wppm.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[inactive_environment_common]', '[active_environment_common]' )" \ No newline at end of file diff --git a/portable/scripts/make_working_directory_be_not_winpython.bat b/portable/scripts/make_working_directory_be_not_winpython.bat index 759de71d..7a726169 100644 --- a/portable/scripts/make_working_directory_be_not_winpython.bat +++ b/portable/scripts/make_working_directory_be_not_winpython.bat @@ -1,3 +1,3 @@ call "%~dp0env_for_icons.bat" -"%PYTHON%" -c "from winpython.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[active_environment', '[inactive_environment' )" -"%PYTHON%" -c "from winpython.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[inactive_environment_per_user]', '[active_environment_per_user]' )" \ No newline at end of file +"%PYTHON%" -c "from wppm.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[active_environment', '[inactive_environment' )" +"%PYTHON%" -c "from wppm.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[inactive_environment_per_user]', '[active_environment_per_user]' )" \ No newline at end of file diff --git a/portable/scripts/make_working_directory_be_winpython.bat b/portable/scripts/make_working_directory_be_winpython.bat index bcdd3c45..0f422a4e 100644 --- a/portable/scripts/make_working_directory_be_winpython.bat +++ b/portable/scripts/make_working_directory_be_winpython.bat @@ -1,2 +1,2 @@ call "%~dp0env_for_icons.bat" -"%PYTHON%" -c "from winpython.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[active_environment', '[inactive_environment' )" +"%PYTHON%" -c "from wppm.utils import patch_sourcefile;patch_sourcefile(r'%~dp0..\settings\winpython.ini', '[active_environment', '[inactive_environment' )" diff --git a/portable/scripts/upgrade_pip.bat b/portable/scripts/upgrade_pip.bat index da212592..4443ed16 100644 --- a/portable/scripts/upgrade_pip.bat +++ b/portable/scripts/upgrade_pip.bat @@ -3,5 +3,5 @@ call "%~dp0env.bat" echo this will upgrade pip with latest version, then patch it for WinPython portability ok ? pause "%WINPYDIR%\python.exe" -m pip install --upgrade pip -"%WINPYDIR%\python.exe" -c "from winpython import wppm;dist=wppm.Distribution(r'%WINPYDIR%');dist.patch_standard_packages('pip', to_movable=True) +"%WINPYDIR%\python.exe" -c "from wppm import wppm;dist=wppm.Distribution(r'%WINPYDIR%');dist.patch_standard_packages('pip', to_movable=True) pause \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2e96abec..4460ae5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,13 +3,13 @@ requires = ["flit_core"] build-backend = "flit_core.buildapi" [project] -name = "winpython" +name = "wppm" authors = [ {name = "Pierre Raybaut"}, {name = "stonebig"}, ] dependencies = [] -requires-python = ">=3.8" +requires-python = ">=3.10" readme = "README.rst" license = {file = "LICENSE"} classifiers=[ @@ -25,7 +25,7 @@ classifiers=[ 'Topic :: Software Development :: Widget Sets', ] dynamic = ["version",] -description="WinPython distribution tools, including WPPM" +description="WinPython Package Management" keywords = ["Portable","Windows"] [project.urls] @@ -33,4 +33,4 @@ Documentation = "https://winpython.github.io/" Source = "https://github.com/winpython/winpython" [project.scripts] -wppm = "winpython.wppm:main" +wppm = "wppm.wppm:main" diff --git a/winpython/__init__.py b/wppm/__init__.py similarity index 95% rename from winpython/__init__.py rename to wppm/__init__.py index b6c1d768..16dcd6d5 100644 --- a/winpython/__init__.py +++ b/wppm/__init__.py @@ -1,33 +1,33 @@ -# -*- coding: utf-8 -*- -""" -WinPython License Agreement (MIT License) ------------------------------------------ - -Copyright (c) 2012-2013 Pierre Raybaut -Copyright (c) 2014-2025+ The Winpython development team https://github.com/winpython/ - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. -""" - -__version__ = '16.6.20250620' -__license__ = __doc__ -__project_url__ = 'http://winpython.github.io/' +# -*- coding: utf-8 -*- +""" +WinPython License Agreement (MIT License) +----------------------------------------- + +Copyright (c) 2012-2013 Pierre Raybaut +Copyright (c) 2014-2025+ The Winpython development team https://github.com/winpython/ + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. +""" + +__version__ = '17.0.20250628' +__license__ = __doc__ +__project_url__ = 'http://winpython.github.io/' diff --git a/winpython/associate.py b/wppm/associate.py similarity index 97% rename from winpython/associate.py rename to wppm/associate.py index 894d8bda..9e02182b 100644 --- a/winpython/associate.py +++ b/wppm/associate.py @@ -1,300 +1,300 @@ -# -*- coding: utf-8 -*- -# -# associate.py = Register a Python distribution -# Copyright © 2012 Pierre Raybaut -# Licensed under the terms of the MIT License -# (see winpython/__init__.py for details) - -import sys -import os -from pathlib import Path -import importlib.util -import winreg -from . import utils -from argparse import ArgumentParser - -def get_special_folder_path(path_name): - """Return special folder path.""" - from win32com.shell import shell, shellcon - try: - csidl = getattr(shellcon, path_name) - return shell.SHGetSpecialFolderPath(0, csidl, False) - except OSError: - print(f"{path_name} is an unknown path ID") - -def get_winpython_start_menu_folder(current=True): - """Return WinPython Start menu shortcuts folder.""" - folder = get_special_folder_path("CSIDL_PROGRAMS") - if not current: - try: - folder = get_special_folder_path("CSIDL_COMMON_PROGRAMS") - except OSError: - pass - return str(Path(folder) / 'WinPython') - -def remove_winpython_start_menu_folder(current=True): - """Remove WinPython Start menu folder -- remove it if it already exists""" - path = get_winpython_start_menu_folder(current=current) - if Path(path).is_dir(): - try: - shutil.rmtree(path, onexc=onerror) - except WindowsError: - print(f"Directory {path} could not be removed", file=sys.stderr) - -def create_winpython_start_menu_folder(current=True): - """Create WinPython Start menu folder.""" - path = get_winpython_start_menu_folder(current=current) - if Path(path).is_dir(): - try: - shutil.rmtree(path, onexc=onerror) - except WindowsError: - print(f"Directory {path} could not be removed", file=sys.stderr) - Path(path).mkdir(parents=True, exist_ok=True) - return path - -def create_shortcut(path, description, filename, arguments="", workdir="", iconpath="", iconindex=0, verbose=True): - """Create Windows shortcut (.lnk file).""" - import pythoncom - from win32com.shell import shell - ilink = pythoncom.CoCreateInstance(shell.CLSID_ShellLink, None, pythoncom.CLSCTX_INPROC_SERVER, shell.IID_IShellLink) - ilink.SetPath(path) - ilink.SetDescription(description) - if arguments: - ilink.SetArguments(arguments) - if workdir: - ilink.SetWorkingDirectory(workdir) - if iconpath or iconindex: - ilink.SetIconLocation(iconpath, iconindex) - # now save it. - ipf = ilink.QueryInterface(pythoncom.IID_IPersistFile) - if not filename.endswith('.lnk'): - filename += '.lnk' - if verbose: - print(f'create menu *{filename}*') - try: - ipf.Save(filename, 0) - except: - print("a fail !") - -# --- 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 - with winreg.CreateKeyEx(root, key_path, 0, winreg.KEY_WRITE) as key: - winreg.SetValueEx(key, name, 0, reg_type, value) - except OSError as e: - print(f"Error creating/setting registry value {rootkey_name}\\{key_path}\\{name}: {e}", file=sys.stderr) - -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: - # 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) - except FileNotFoundError: - if verbose: - print(f"Registry key not found (skipping deletion): {rootkey_name}\\{key_path}") - except OSError as e: # Catch other potential errors like key not empty - print(f"Error deleting registry key {rootkey_name}\\{key_path}: {e}", file=sys.stderr) - - -# --- Helper functions for Start Menu Shortcuts --- - -def _has_pywin32(): - """Check if pywin32 (pythoncom) is installed.""" - return importlib.util.find_spec('pythoncom') is not None - -def _remove_start_menu_folder(target, current=True, has_pywin32=False): - "remove menu Folder for target WinPython if pywin32 exists" - if has_pywin32: - remove_winpython_start_menu_folder(current=current) - else: - print("Skipping start menu removal as pywin32 package is not installed.") - -def _get_shortcut_data(target, current=True, has_pywin32=False): - "get windows menu access data if pywin32 exists, otherwise empty list" - if not has_pywin32: - return [] - - wpdir = str(Path(target).parent) - data = [] - for name in os.listdir(wpdir): - bname, ext = Path(name).stem, Path(name).suffix - if ext.lower() == ".exe": - # Path for the shortcut file in the start menu folder - shortcut_name = str(Path(create_winpython_start_menu_folder(current=current)) / bname) + '.lnk' - data.append( - ( - str(Path(wpdir) / name), # Target executable path - bname, # Description/Name - shortcut_name, # Shortcut file path - ) - ) - return data - -# --- PythonCore entries (PEP-0514 and WinPython specific) --- - - -def register_in_registery(target, current=True, reg_type=winreg.REG_SZ, verbose=True) -> tuple[list[any], ...]: - """Register in Windows (like regedit)""" - - # --- 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_path / "DLLs" / "py.ico") - icon_pyc = str(target_path / "DLLs" / "pyc.ico") - idle_path = str(target_path / "Lib" / "idlelib" / "idle.pyw") - doc_path = str(target_path / "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}" "%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" -w "%w"')) - dynamic_entries.append((rf"Software\Classes\Python.NoConFile\shell\Edit with Spyder\command", None, f'"{spyder_exe}" "%1" -w "%w"')) - 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 --- - -def register(target, current=True, reg_type=winreg.REG_SZ, verbose=True): - """Register a Python distribution in Windows registry and create Start Menu shortcuts""" - root = winreg.HKEY_CURRENT_USER if current else winreg.HKEY_LOCAL_MACHINE - has_pywin32 = _has_pywin32() - - if verbose: - print(f'Creating WinPython registry entries for {target}') - - 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: - print(f'Creating WinPython menu for all icons in {target.parent}') - for path, desc, fname in _get_shortcut_data(target, current=current, has_pywin32=True): - try: - create_shortcut(path, desc, fname, verbose=verbose) - 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.") - -def unregister(target, current=True, verbose=True): - """Unregister a Python distribution from Windows registry and remove Start Menu shortcuts""" - root = winreg.HKEY_CURRENT_USER if current else winreg.HKEY_LOCAL_MACHINE - has_pywin32 = _has_pywin32() - - 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 = 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: - if verbose: - 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. - 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 "\ - "and Windows explorer context menu to this "\ - "Python distribution.") - parser.add_argument('--unregister', action="store_true", - help='register to all users, requiring administrative '\ - 'privileges (default: register to current user only)') - parser.add_argument('--all', action="store_true", - help='action is to all users, requiring administrative '\ - 'privileges (default: to current user only)') - args = parser.parse_args() - expected_target = Path(sys.prefix) - command = "unregister" if args.unregister else "register" - users = "all" if args.all else "user" - print(f"Attempting to {command} the Python environment for {users} at: {expected_target}") - - target_dir = sys.prefix # Or get from arguments - is_current_user = True # Or get from arguments - if command == "register": - register(expected_target, current=not args.all) - else: - unregister(expected_target, current=not args.all) +# -*- coding: utf-8 -*- +# +# associate.py = Register a Python distribution +# Copyright © 2012 Pierre Raybaut +# Licensed under the terms of the MIT License +# (see winpython/__init__.py for details) + +import sys +import os +from pathlib import Path +import importlib.util +import winreg +from . import utils +from argparse import ArgumentParser + +def get_special_folder_path(path_name): + """Return special folder path.""" + from win32com.shell import shell, shellcon + try: + csidl = getattr(shellcon, path_name) + return shell.SHGetSpecialFolderPath(0, csidl, False) + except OSError: + print(f"{path_name} is an unknown path ID") + +def get_winpython_start_menu_folder(current=True): + """Return WinPython Start menu shortcuts folder.""" + folder = get_special_folder_path("CSIDL_PROGRAMS") + if not current: + try: + folder = get_special_folder_path("CSIDL_COMMON_PROGRAMS") + except OSError: + pass + return str(Path(folder) / 'WinPython') + +def remove_winpython_start_menu_folder(current=True): + """Remove WinPython Start menu folder -- remove it if it already exists""" + path = get_winpython_start_menu_folder(current=current) + if Path(path).is_dir(): + try: + shutil.rmtree(path, onexc=onerror) + except WindowsError: + print(f"Directory {path} could not be removed", file=sys.stderr) + +def create_winpython_start_menu_folder(current=True): + """Create WinPython Start menu folder.""" + path = get_winpython_start_menu_folder(current=current) + if Path(path).is_dir(): + try: + shutil.rmtree(path, onexc=onerror) + except WindowsError: + print(f"Directory {path} could not be removed", file=sys.stderr) + Path(path).mkdir(parents=True, exist_ok=True) + return path + +def create_shortcut(path, description, filename, arguments="", workdir="", iconpath="", iconindex=0, verbose=True): + """Create Windows shortcut (.lnk file).""" + import pythoncom + from win32com.shell import shell + ilink = pythoncom.CoCreateInstance(shell.CLSID_ShellLink, None, pythoncom.CLSCTX_INPROC_SERVER, shell.IID_IShellLink) + ilink.SetPath(path) + ilink.SetDescription(description) + if arguments: + ilink.SetArguments(arguments) + if workdir: + ilink.SetWorkingDirectory(workdir) + if iconpath or iconindex: + ilink.SetIconLocation(iconpath, iconindex) + # now save it. + ipf = ilink.QueryInterface(pythoncom.IID_IPersistFile) + if not filename.endswith('.lnk'): + filename += '.lnk' + if verbose: + print(f'create menu *{filename}*') + try: + ipf.Save(filename, 0) + except: + print("a fail !") + +# --- 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 + with winreg.CreateKeyEx(root, key_path, 0, winreg.KEY_WRITE) as key: + winreg.SetValueEx(key, name, 0, reg_type, value) + except OSError as e: + print(f"Error creating/setting registry value {rootkey_name}\\{key_path}\\{name}: {e}", file=sys.stderr) + +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: + # 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) + except FileNotFoundError: + if verbose: + print(f"Registry key not found (skipping deletion): {rootkey_name}\\{key_path}") + except OSError as e: # Catch other potential errors like key not empty + print(f"Error deleting registry key {rootkey_name}\\{key_path}: {e}", file=sys.stderr) + + +# --- Helper functions for Start Menu Shortcuts --- + +def _has_pywin32(): + """Check if pywin32 (pythoncom) is installed.""" + return importlib.util.find_spec('pythoncom') is not None + +def _remove_start_menu_folder(target, current=True, has_pywin32=False): + "remove menu Folder for target WinPython if pywin32 exists" + if has_pywin32: + remove_winpython_start_menu_folder(current=current) + else: + print("Skipping start menu removal as pywin32 package is not installed.") + +def _get_shortcut_data(target, current=True, has_pywin32=False): + "get windows menu access data if pywin32 exists, otherwise empty list" + if not has_pywin32: + return [] + + wpdir = str(Path(target).parent) + data = [] + for name in os.listdir(wpdir): + bname, ext = Path(name).stem, Path(name).suffix + if ext.lower() == ".exe": + # Path for the shortcut file in the start menu folder + shortcut_name = str(Path(create_winpython_start_menu_folder(current=current)) / bname) + '.lnk' + data.append( + ( + str(Path(wpdir) / name), # Target executable path + bname, # Description/Name + shortcut_name, # Shortcut file path + ) + ) + return data + +# --- PythonCore entries (PEP-0514 and WinPython specific) --- + + +def register_in_registery(target, current=True, reg_type=winreg.REG_SZ, verbose=True) -> tuple[list[any], ...]: + """Register in Windows (like regedit)""" + + # --- 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_path / "DLLs" / "py.ico") + icon_pyc = str(target_path / "DLLs" / "pyc.ico") + idle_path = str(target_path / "Lib" / "idlelib" / "idle.pyw") + doc_path = str(target_path / "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}" "%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" -w "%w"')) + dynamic_entries.append((rf"Software\Classes\Python.NoConFile\shell\Edit with Spyder\command", None, f'"{spyder_exe}" "%1" -w "%w"')) + 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 --- + +def register(target, current=True, reg_type=winreg.REG_SZ, verbose=True): + """Register a Python distribution in Windows registry and create Start Menu shortcuts""" + root = winreg.HKEY_CURRENT_USER if current else winreg.HKEY_LOCAL_MACHINE + has_pywin32 = _has_pywin32() + + if verbose: + print(f'Creating WinPython registry entries for {target}') + + 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: + print(f'Creating WinPython menu for all icons in {target.parent}') + for path, desc, fname in _get_shortcut_data(target, current=current, has_pywin32=True): + try: + create_shortcut(path, desc, fname, verbose=verbose) + 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.") + +def unregister(target, current=True, verbose=True): + """Unregister a Python distribution from Windows registry and remove Start Menu shortcuts""" + root = winreg.HKEY_CURRENT_USER if current else winreg.HKEY_LOCAL_MACHINE + has_pywin32 = _has_pywin32() + + 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 = 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: + if verbose: + 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. + 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 "\ + "and Windows explorer context menu to this "\ + "Python distribution.") + parser.add_argument('--unregister', action="store_true", + help='register to all users, requiring administrative '\ + 'privileges (default: register to current user only)') + parser.add_argument('--all', action="store_true", + help='action is to all users, requiring administrative '\ + 'privileges (default: to current user only)') + args = parser.parse_args() + expected_target = Path(sys.prefix) + command = "unregister" if args.unregister else "register" + users = "all" if args.all else "user" + print(f"Attempting to {command} the Python environment for {users} at: {expected_target}") + + target_dir = sys.prefix # Or get from arguments + is_current_user = True # Or get from arguments + if command == "register": + register(expected_target, current=not args.all) + else: + unregister(expected_target, current=not args.all) diff --git a/winpython/diff.py b/wppm/diff.py similarity index 100% rename from winpython/diff.py rename to wppm/diff.py diff --git a/winpython/hash.py b/wppm/hash.py similarity index 100% rename from winpython/hash.py rename to wppm/hash.py diff --git a/winpython/packagemetadata.py b/wppm/packagemetadata.py similarity index 100% rename from winpython/packagemetadata.py rename to wppm/packagemetadata.py diff --git a/winpython/piptree.py b/wppm/piptree.py similarity index 100% rename from winpython/piptree.py rename to wppm/piptree.py diff --git a/winpython/utils.py b/wppm/utils.py similarity index 97% rename from winpython/utils.py rename to wppm/utils.py index 0c83cb80..dcec0207 100644 --- a/winpython/utils.py +++ b/wppm/utils.py @@ -1,313 +1,313 @@ -# -*- coding: utf-8 -*- -# -# WinPython utilities -# Copyright © 2012 Pierre Raybaut -# Copyright © 2014-2025+ The Winpython development team https://github.com/winpython/ -# Licensed under the terms of the MIT License -# (see winpython/__init__.py for details) - -import os -import sys -import stat -import shutil -import locale -import subprocess -from pathlib import Path -import re -import tarfile -import zipfile - -# SOURCE_PATTERN defines what an acceptable source package name is -SOURCE_PATTERN = r'([a-zA-Z0-9\-\_\.]*)-([0-9\.\_]*[a-z]*[\-]?[0-9]*)(\.zip|\.tar\.gz|\-(py[2-7]*|py[2-7]*\.py[2-7]*)\-none\-any\.whl)' - -# WHEELBIN_PATTERN defines what an acceptable binary wheel package is -WHEELBIN_PATTERN = r'([a-zA-Z0-9\-\_\.]*)-([0-9\.\_]*[a-z0-9\+]*[0-9]?)-cp([0-9]*)\-[0-9|c|o|n|e|p|m]*\-(win32|win\_amd64)\.whl' - -def get_python_executable(path=None): - """Return the path to the Python executable.""" - python_path = Path(path) if path else Path(sys.executable) - base_dir = python_path if python_path.is_dir() else python_path.parent - python_exe = base_dir / 'python.exe' - pypy_exe = base_dir / 'pypy3.exe' # For PyPy - return str(python_exe if python_exe.is_file() else pypy_exe) - -def get_site_packages_path(path=None): - """Return the path to the Python site-packages directory.""" - python_path = Path(path) if path else Path(sys.executable) - base_dir = python_path if python_path.is_dir() else python_path.parent - site_packages = base_dir / 'Lib' / 'site-packages' - pypy_site_packages = base_dir / 'site-packages' # For PyPy - return str(pypy_site_packages if pypy_site_packages.is_dir() else site_packages) - -def get_installed_tools(path=None)-> str: - """Generates Markdown for installed tools section in package index.""" - tool_lines = [] - python_exe = Path(get_python_executable(path)) - version = exec_shell_cmd(f'powershell (Get-Item {python_exe}).VersionInfo.FileVersion', python_exe.parent).splitlines()[0] - tool_lines.append(("Python" ,f"http://www.python.org/", version, "Python programming language with standard library")) - if (node_exe := python_exe.parent.parent / "n" / "node.exe").exists(): - version = exec_shell_cmd(f'powershell (Get-Item {node_exe}).VersionInfo.FileVersion', node_exe.parent).splitlines()[0] - tool_lines.append(("Nodejs", "https://nodejs.org", version, "a JavaScript runtime built on Chrome's V8 JavaScript engine")) - - if (pandoc_exe := python_exe.parent.parent / "t" / "pandoc.exe").exists(): - version = exec_shell_cmd("pandoc -v", pandoc_exe.parent).splitlines()[0].split(" ")[-1] - tool_lines.append(("Pandoc", "https://pandoc.org", version, "an universal document converter")) - - if (vscode_exe := python_exe.parent.parent / "t" / "VSCode" / "Code.exe").exists(): - version = exec_shell_cmd(f'powershell (Get-Item {vscode_exe}).VersionInfo.FileVersion', vscode_exe.parent).splitlines()[0] - tool_lines.append(("VSCode","https://code.visualstudio.com", version, "a source-code editor developed by Microsoft")) - return tool_lines - -def onerror(function, path, excinfo): - """Error handler for `shutil.rmtree`.""" - if not os.access(path, os.W_OK): - os.chmod(path, stat.S_IWUSR) - function(path) - else: - raise - -def sum_up(text: str, max_length: int = 144, stop_at: str = ". ") -> str: - """Summarize text to fit within max_length, ending at last complete sentence.""" - summary = (text + os.linesep).splitlines()[0].strip() - if len(summary) <= max_length: - return summary - if stop_at and stop_at in summary[:max_length]: - return summary[:summary.rfind(stop_at, 0, max_length)] + stop_at.strip() - return summary[:max_length].strip() - -def print_box(text): - """Print text in a box""" - line0 = "+" + ("-" * (len(text) + 2)) + "+" - line1 = "| " + text + " |" - print("\n\n" + "\n".join([line0, line1, line0]) + "\n") - -def is_python_distribution(path): - """Return True if path is a Python distribution.""" - has_exec = Path(get_python_executable(path)).is_file() - has_site = Path(get_site_packages_path(path)).is_dir() - return has_exec and has_site - -def decode_fs_string(string): - """Convert string from file system charset to unicode.""" - charset = sys.getfilesystemencoding() or locale.getpreferredencoding() - return string.decode(charset) - -def exec_shell_cmd(args, path): - """Execute shell command (*args* is a list of arguments) in *path*.""" - process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=path, shell=True) - return decode_fs_string(process.stdout.read()) - -def exec_run_cmd(args, path=None): - """Run a single command (*args* is a list of arguments) in optional *path*.""" - process = subprocess.run(args, capture_output=True, cwd=path, text=True) - return process.stdout - -def python_query(cmd, path): - """Execute Python command using the Python interpreter located in *path*.""" - the_exe = get_python_executable(path) - return exec_shell_cmd(f'"{the_exe}" -c "{cmd}"', path).splitlines()[0] - -def python_execmodule(cmd, path): - """Execute Python command using the Python interpreter located in *path*.""" - the_exe = get_python_executable(path) - exec_shell_cmd(f'{the_exe} -m {cmd}', path) - -def get_python_infos(path): - """Return (version, architecture) for the Python distribution located in *path*.""" - is_64 = python_query("import sys; print(sys.maxsize > 2**32)", path) - arch = {"True": 64, "False": 32}.get(is_64, None) - ver = python_query("import sys;print(f'{sys.version_info.major}.{sys.version_info.minor}')", path) - return ver, arch - -def get_python_long_version(path): - """Return long version (X.Y.Z) for the Python distribution located in *path*.""" - ver = python_query("import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')", path) - return ver if re.match(r"([0-9]*)\.([0-9]*)\.([0-9]*)", ver) else None - -def patch_shebang_line(fname, pad=b" ", to_movable=True, targetdir=""): - """Remove absolute path to python.exe in shebang lines in binary files, or re-add it.""" - target_dir = targetdir if to_movable else os.path.abspath(os.path.join(os.path.dirname(fname), r"..")) + "\\" - executable = sys.executable - shebang_line = re.compile(rb"""(#!.*pythonw?\.exe)"?""") # Python3+ - if "pypy3" in sys.executable: - shebang_line = re.compile(rb"""(#!.*pypy3w?\.exe)"?""") # Pypy3+ - target_dir = target_dir.encode("utf-8") - - with open(fname, "rb") as fh: - initial_content = fh.read() - content = shebang_line.split(initial_content, maxsplit=1) - if len(content) != 3: - return - exe = os.path.basename(content[1][2:]) - content[1] = b"#!" + target_dir + exe # + (pad * (len(content[1]) - len(exe) - 2)) - final_content = b"".join(content) - if initial_content == final_content: - return - try: - with open(fname, "wb") as fo: - fo.write(final_content) - print("patched", fname) - except Exception: - print("failed to patch", fname) - -def patch_shebang_line_py(fname, to_movable=True, targetdir=""): - """Changes shebang line in '.py' file to relative or absolue path""" - import fileinput - exec_path = r'#!.\python.exe' if to_movable else '#!' + sys.executable - if 'pypy3' in sys.executable: - exec_path = r'#!.\pypy3.exe' if to_movable else exec_path - for line in fileinput.input(fname, inplace=True): - if re.match(r'^#\!.*python\.exe$', line) or re.match(r'^#\!.*pypy3\.exe$', line): - print(exec_path) - else: - print(line, end='') - -def guess_encoding(csv_file): - """guess the encoding of the given file""" - with open(csv_file, "rb") as f: - data = f.read(5) - if data.startswith(b"\xEF\xBB\xBF"): # UTF-8 with a "BOM" (normally no BOM in utf-8) - return ["utf-8-sig"] - try: - with open(csv_file, encoding="utf-8") as f: - preview = f.read(222222) - return ["utf-8"] - except: - return [locale.getdefaultlocale()[1], "utf-8"] - -def replace_in_file(filepath: Path, replacements: list[tuple[str, str]], filedest: Path = None, verbose=False): - """ - Replaces strings in a file - Args: - filepath: Path to the file to modify. - replacements: A list of tuples of ('old string 'new string') - filedest: optional output file, otherwise will be filepath - """ - the_encoding = guess_encoding(filepath)[0] - with open(filepath, "r", encoding=the_encoding) as f: - content = f.read() - new_content = content - for old_text, new_text in replacements: - new_content = new_content.replace(old_text, new_text) - outfile = filedest if filedest else filepath - if new_content != content or str(outfile) != str(filepath): - with open(outfile, "w", encoding=the_encoding) as f: - f.write(new_content) - if verbose: - print(f"patched from {Path(filepath).name} into {outfile} !") - -def patch_sourcefile(fname, in_text, out_text, silent_mode=False): - """Replace a string in a source file.""" - if not silent_mode: - print(f"patching {fname} from {in_text} to {out_text}") - if Path(fname).is_file() and in_text != out_text: - replace_in_file(Path(fname), [(in_text, out_text)]) - -def extract_archive(fname, targetdir=None, verbose=False): - """Extract .zip, .exe or .tar.gz archive to a temporary directory. - Return the temporary directory path""" - targetdir = targetdir or create_temp_dir() - Path(targetdir).mkdir(parents=True, exist_ok=True) - if Path(fname).suffix in ('.zip', '.exe'): - obj = zipfile.ZipFile(fname, mode="r") - elif fname.endswith('.tar.gz'): - obj = tarfile.open(fname, mode='r:gz') - else: - raise RuntimeError(f"Unsupported archive filename {fname}") - obj.extractall(path=targetdir) - return targetdir - -def get_source_package_infos(fname): - """Return a tuple (name, version) of the Python source package.""" - if fname.endswith('.whl'): - return Path(fname).name.split("-")[:2] - match = re.match(SOURCE_PATTERN, Path(fname).name) - return match.groups()[:2] if match else None - -def buildflit_wininst(root, python_exe=None, copy_to=None, verbose=False): - """Build Wheel from Python package located in *root* with flit.""" - python_exe = python_exe or sys.executable - cmd = [python_exe, '-m', 'flit', 'build'] - if verbose: - subprocess.call(cmd, cwd=root) - else: - subprocess.Popen(cmd, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate() - distdir = Path(root) / 'dist' - if not distdir.is_dir(): - raise RuntimeError( - "Build failed: see package README file for further details regarding installation requirements.\n\n" - "For more concrete debugging infos, please try to build the package from the command line:\n" - "1. Open a WinPython command prompt\n" - "2. Change working directory to the appropriate folder\n" - "3. Type `python -m flit build`" - ) - for distname in os.listdir(distdir): - if re.match(SOURCE_PATTERN, distname) or re.match(WHEELBIN_PATTERN, distname): - break - else: - raise RuntimeError(f"Build failed: not a pure Python package? {distdir}") - - src_fname = distdir / distname - if copy_to: - dst_fname = Path(copy_to) / distname - shutil.move(src_fname, dst_fname) - if verbose: - print(f"Move: {src_fname} --> {dst_fname}") - -def direct_pip_install(fname, python_exe=None, verbose=False, install_options=None): - """Direct install via python -m pip !""" - python_exe = python_exe or sys.executable - myroot = Path(python_exe).parent - cmd = [python_exe, "-m", "pip", "install"] + (install_options or []) + [fname] - if not verbose: - process = subprocess.Popen(cmd, cwd=myroot, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - stdout, stderr = process.communicate() - the_log = f"{stdout}\n {stderr}" - if " not find " in the_log or " not found " in the_log: - print(f"Failed to Install: \n {fname} \n msg: {the_log}") - raise RuntimeError - process.stdout.close() - process.stderr.close() - else: - subprocess.call(cmd, cwd=myroot) - print(f"Installed {fname} via {' '.join(cmd)}") - return fname - -def do_script(this_script, python_exe=None, copy_to=None, verbose=False, install_options=None): - """Execute a script (get-pip typically).""" - python_exe = python_exe or sys.executable - myroot = Path(python_exe).parent - # cmd = [python_exe, myroot + r'\Scripts\pip-script.py', 'install'] - cmd = [python_exe] + (install_options or []) + ([this_script] if this_script else []) - print("Executing ", cmd) - if not verbose: - subprocess.Popen(cmd, cwd=myroot, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate() - else: - subprocess.call(cmd, cwd=myroot) - print("Executed ", cmd) - return 'ok' - -def columns_width(list_of_lists): - """Return the maximum string length of each column of a list of lists.""" - if not isinstance(list_of_lists, list): - return [0] - return [max(len(str(item)) for item in sublist) for sublist in zip(*list_of_lists)] - -def formatted_list(list_of_list, full=False, max_width=70): - """Format a list_of_list to fixed length columns.""" - columns_size = columns_width(list_of_list) - columns = range(len(columns_size)) - return [list(line[col].ljust(columns_size[col])[:max_width] for col in columns) for line in list_of_list] - -def normalize(this): - """Apply PEP 503 normalization to the string.""" - return re.sub(r"[-_.]+", "-", this).lower() - -if __name__ == '__main__': - print_box("Test") - dname = sys.prefix - print((dname + ':', '\n', get_python_infos(dname))) - - tmpdir = r'D:\Tests\winpython_tests' - Path(tmpdir).mkdir(parents=True, exist_ok=True) - print(extract_archive(str(Path(r'D:\WinP\bd37') / 'packages.win-amd64' / 'python-3.7.3.amd64.zip'), tmpdir)) +# -*- coding: utf-8 -*- +# +# WinPython utilities +# Copyright © 2012 Pierre Raybaut +# Copyright © 2014-2025+ The Winpython development team https://github.com/winpython/ +# Licensed under the terms of the MIT License +# (see winpython/__init__.py for details) + +import os +import sys +import stat +import shutil +import locale +import subprocess +from pathlib import Path +import re +import tarfile +import zipfile + +# SOURCE_PATTERN defines what an acceptable source package name is +SOURCE_PATTERN = r'([a-zA-Z0-9\-\_\.]*)-([0-9\.\_]*[a-z]*[\-]?[0-9]*)(\.zip|\.tar\.gz|\-(py[2-7]*|py[2-7]*\.py[2-7]*)\-none\-any\.whl)' + +# WHEELBIN_PATTERN defines what an acceptable binary wheel package is +WHEELBIN_PATTERN = r'([a-zA-Z0-9\-\_\.]*)-([0-9\.\_]*[a-z0-9\+]*[0-9]?)-cp([0-9]*)\-[0-9|c|o|n|e|p|m]*\-(win32|win\_amd64)\.whl' + +def get_python_executable(path=None): + """Return the path to the Python executable.""" + python_path = Path(path) if path else Path(sys.executable) + base_dir = python_path if python_path.is_dir() else python_path.parent + python_exe = base_dir / 'python.exe' + pypy_exe = base_dir / 'pypy3.exe' # For PyPy + return str(python_exe if python_exe.is_file() else pypy_exe) + +def get_site_packages_path(path=None): + """Return the path to the Python site-packages directory.""" + python_path = Path(path) if path else Path(sys.executable) + base_dir = python_path if python_path.is_dir() else python_path.parent + site_packages = base_dir / 'Lib' / 'site-packages' + pypy_site_packages = base_dir / 'site-packages' # For PyPy + return str(pypy_site_packages if pypy_site_packages.is_dir() else site_packages) + +def get_installed_tools(path=None)-> str: + """Generates Markdown for installed tools section in package index.""" + tool_lines = [] + python_exe = Path(get_python_executable(path)) + version = exec_shell_cmd(f'powershell (Get-Item {python_exe}).VersionInfo.FileVersion', python_exe.parent).splitlines()[0] + tool_lines.append(("Python" ,f"http://www.python.org/", version, "Python programming language with standard library")) + if (node_exe := python_exe.parent.parent / "n" / "node.exe").exists(): + version = exec_shell_cmd(f'powershell (Get-Item {node_exe}).VersionInfo.FileVersion', node_exe.parent).splitlines()[0] + tool_lines.append(("Nodejs", "https://nodejs.org", version, "a JavaScript runtime built on Chrome's V8 JavaScript engine")) + + if (pandoc_exe := python_exe.parent.parent / "t" / "pandoc.exe").exists(): + version = exec_shell_cmd("pandoc -v", pandoc_exe.parent).splitlines()[0].split(" ")[-1] + tool_lines.append(("Pandoc", "https://pandoc.org", version, "an universal document converter")) + + if (vscode_exe := python_exe.parent.parent / "t" / "VSCode" / "Code.exe").exists(): + version = exec_shell_cmd(f'powershell (Get-Item {vscode_exe}).VersionInfo.FileVersion', vscode_exe.parent).splitlines()[0] + tool_lines.append(("VSCode","https://code.visualstudio.com", version, "a source-code editor developed by Microsoft")) + return tool_lines + +def onerror(function, path, excinfo): + """Error handler for `shutil.rmtree`.""" + if not os.access(path, os.W_OK): + os.chmod(path, stat.S_IWUSR) + function(path) + else: + raise + +def sum_up(text: str, max_length: int = 144, stop_at: str = ". ") -> str: + """Summarize text to fit within max_length, ending at last complete sentence.""" + summary = (text + os.linesep).splitlines()[0].strip() + if len(summary) <= max_length: + return summary + if stop_at and stop_at in summary[:max_length]: + return summary[:summary.rfind(stop_at, 0, max_length)] + stop_at.strip() + return summary[:max_length].strip() + +def print_box(text): + """Print text in a box""" + line0 = "+" + ("-" * (len(text) + 2)) + "+" + line1 = "| " + text + " |" + print("\n\n" + "\n".join([line0, line1, line0]) + "\n") + +def is_python_distribution(path): + """Return True if path is a Python distribution.""" + has_exec = Path(get_python_executable(path)).is_file() + has_site = Path(get_site_packages_path(path)).is_dir() + return has_exec and has_site + +def decode_fs_string(string): + """Convert string from file system charset to unicode.""" + charset = sys.getfilesystemencoding() or locale.getpreferredencoding() + return string.decode(charset) + +def exec_shell_cmd(args, path): + """Execute shell command (*args* is a list of arguments) in *path*.""" + process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=path, shell=True) + return decode_fs_string(process.stdout.read()) + +def exec_run_cmd(args, path=None): + """Run a single command (*args* is a list of arguments) in optional *path*.""" + process = subprocess.run(args, capture_output=True, cwd=path, text=True) + return process.stdout + +def python_query(cmd, path): + """Execute Python command using the Python interpreter located in *path*.""" + the_exe = get_python_executable(path) + return exec_shell_cmd(f'"{the_exe}" -c "{cmd}"', path).splitlines()[0] + +def python_execmodule(cmd, path): + """Execute Python command using the Python interpreter located in *path*.""" + the_exe = get_python_executable(path) + exec_shell_cmd(f'{the_exe} -m {cmd}', path) + +def get_python_infos(path): + """Return (version, architecture) for the Python distribution located in *path*.""" + is_64 = python_query("import sys; print(sys.maxsize > 2**32)", path) + arch = {"True": 64, "False": 32}.get(is_64, None) + ver = python_query("import sys;print(f'{sys.version_info.major}.{sys.version_info.minor}')", path) + return ver, arch + +def get_python_long_version(path): + """Return long version (X.Y.Z) for the Python distribution located in *path*.""" + ver = python_query("import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')", path) + return ver if re.match(r"([0-9]*)\.([0-9]*)\.([0-9]*)", ver) else None + +def patch_shebang_line(fname, pad=b" ", to_movable=True, targetdir=""): + """Remove absolute path to python.exe in shebang lines in binary files, or re-add it.""" + target_dir = targetdir if to_movable else os.path.abspath(os.path.join(os.path.dirname(fname), r"..")) + "\\" + executable = sys.executable + shebang_line = re.compile(rb"""(#!.*pythonw?\.exe)"?""") # Python3+ + if "pypy3" in sys.executable: + shebang_line = re.compile(rb"""(#!.*pypy3w?\.exe)"?""") # Pypy3+ + target_dir = target_dir.encode("utf-8") + + with open(fname, "rb") as fh: + initial_content = fh.read() + content = shebang_line.split(initial_content, maxsplit=1) + if len(content) != 3: + return + exe = os.path.basename(content[1][2:]) + content[1] = b"#!" + target_dir + exe # + (pad * (len(content[1]) - len(exe) - 2)) + final_content = b"".join(content) + if initial_content == final_content: + return + try: + with open(fname, "wb") as fo: + fo.write(final_content) + print("patched", fname) + except Exception: + print("failed to patch", fname) + +def patch_shebang_line_py(fname, to_movable=True, targetdir=""): + """Changes shebang line in '.py' file to relative or absolue path""" + import fileinput + exec_path = r'#!.\python.exe' if to_movable else '#!' + sys.executable + if 'pypy3' in sys.executable: + exec_path = r'#!.\pypy3.exe' if to_movable else exec_path + for line in fileinput.input(fname, inplace=True): + if re.match(r'^#\!.*python\.exe$', line) or re.match(r'^#\!.*pypy3\.exe$', line): + print(exec_path) + else: + print(line, end='') + +def guess_encoding(csv_file): + """guess the encoding of the given file""" + with open(csv_file, "rb") as f: + data = f.read(5) + if data.startswith(b"\xEF\xBB\xBF"): # UTF-8 with a "BOM" (normally no BOM in utf-8) + return ["utf-8-sig"] + try: + with open(csv_file, encoding="utf-8") as f: + preview = f.read(222222) + return ["utf-8"] + except: + return [locale.getdefaultlocale()[1], "utf-8"] + +def replace_in_file(filepath: Path, replacements: list[tuple[str, str]], filedest: Path = None, verbose=False): + """ + Replaces strings in a file + Args: + filepath: Path to the file to modify. + replacements: A list of tuples of ('old string 'new string') + filedest: optional output file, otherwise will be filepath + """ + the_encoding = guess_encoding(filepath)[0] + with open(filepath, "r", encoding=the_encoding) as f: + content = f.read() + new_content = content + for old_text, new_text in replacements: + new_content = new_content.replace(old_text, new_text) + outfile = filedest if filedest else filepath + if new_content != content or str(outfile) != str(filepath): + with open(outfile, "w", encoding=the_encoding) as f: + f.write(new_content) + if verbose: + print(f"patched from {Path(filepath).name} into {outfile} !") + +def patch_sourcefile(fname, in_text, out_text, silent_mode=False): + """Replace a string in a source file.""" + if not silent_mode: + print(f"patching {fname} from {in_text} to {out_text}") + if Path(fname).is_file() and in_text != out_text: + replace_in_file(Path(fname), [(in_text, out_text)]) + +def extract_archive(fname, targetdir=None, verbose=False): + """Extract .zip, .exe or .tar.gz archive to a temporary directory. + Return the temporary directory path""" + targetdir = targetdir or create_temp_dir() + Path(targetdir).mkdir(parents=True, exist_ok=True) + if Path(fname).suffix in ('.zip', '.exe'): + obj = zipfile.ZipFile(fname, mode="r") + elif fname.endswith('.tar.gz'): + obj = tarfile.open(fname, mode='r:gz') + else: + raise RuntimeError(f"Unsupported archive filename {fname}") + obj.extractall(path=targetdir) + return targetdir + +def get_source_package_infos(fname): + """Return a tuple (name, version) of the Python source package.""" + if fname.endswith('.whl'): + return Path(fname).name.split("-")[:2] + match = re.match(SOURCE_PATTERN, Path(fname).name) + return match.groups()[:2] if match else None + +def buildflit_wininst(root, python_exe=None, copy_to=None, verbose=False): + """Build Wheel from Python package located in *root* with flit.""" + python_exe = python_exe or sys.executable + cmd = [python_exe, '-m', 'flit', 'build'] + if verbose: + subprocess.call(cmd, cwd=root) + else: + subprocess.Popen(cmd, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate() + distdir = Path(root) / 'dist' + if not distdir.is_dir(): + raise RuntimeError( + "Build failed: see package README file for further details regarding installation requirements.\n\n" + "For more concrete debugging infos, please try to build the package from the command line:\n" + "1. Open a WinPython command prompt\n" + "2. Change working directory to the appropriate folder\n" + "3. Type `python -m flit build`" + ) + for distname in os.listdir(distdir): + if re.match(SOURCE_PATTERN, distname) or re.match(WHEELBIN_PATTERN, distname): + break + else: + raise RuntimeError(f"Build failed: not a pure Python package? {distdir}") + + src_fname = distdir / distname + if copy_to: + dst_fname = Path(copy_to) / distname + shutil.move(src_fname, dst_fname) + if verbose: + print(f"Move: {src_fname} --> {dst_fname}") + +def direct_pip_install(fname, python_exe=None, verbose=False, install_options=None): + """Direct install via python -m pip !""" + python_exe = python_exe or sys.executable + myroot = Path(python_exe).parent + cmd = [python_exe, "-m", "pip", "install"] + (install_options or []) + [fname] + if not verbose: + process = subprocess.Popen(cmd, cwd=myroot, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + the_log = f"{stdout}\n {stderr}" + if " not find " in the_log or " not found " in the_log: + print(f"Failed to Install: \n {fname} \n msg: {the_log}") + raise RuntimeError + process.stdout.close() + process.stderr.close() + else: + subprocess.call(cmd, cwd=myroot) + print(f"Installed {fname} via {' '.join(cmd)}") + return fname + +def do_script(this_script, python_exe=None, copy_to=None, verbose=False, install_options=None): + """Execute a script (get-pip typically).""" + python_exe = python_exe or sys.executable + myroot = Path(python_exe).parent + # cmd = [python_exe, myroot + r'\Scripts\pip-script.py', 'install'] + cmd = [python_exe] + (install_options or []) + ([this_script] if this_script else []) + print("Executing ", cmd) + if not verbose: + subprocess.Popen(cmd, cwd=myroot, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate() + else: + subprocess.call(cmd, cwd=myroot) + print("Executed ", cmd) + return 'ok' + +def columns_width(list_of_lists): + """Return the maximum string length of each column of a list of lists.""" + if not isinstance(list_of_lists, list): + return [0] + return [max(len(str(item)) for item in sublist) for sublist in zip(*list_of_lists)] + +def formatted_list(list_of_list, full=False, max_width=70): + """Format a list_of_list to fixed length columns.""" + columns_size = columns_width(list_of_list) + columns = range(len(columns_size)) + return [list(line[col].ljust(columns_size[col])[:max_width] for col in columns) for line in list_of_list] + +def normalize(this): + """Apply PEP 503 normalization to the string.""" + return re.sub(r"[-_.]+", "-", this).lower() + +if __name__ == '__main__': + print_box("Test") + dname = sys.prefix + print((dname + ':', '\n', get_python_infos(dname))) + + tmpdir = r'D:\Tests\winpython_tests' + Path(tmpdir).mkdir(parents=True, exist_ok=True) + print(extract_archive(str(Path(r'D:\WinP\bd37') / 'packages.win-amd64' / 'python-3.7.3.amd64.zip'), tmpdir)) diff --git a/winpython/wheelhouse.py b/wppm/wheelhouse.py similarity index 100% rename from winpython/wheelhouse.py rename to wppm/wheelhouse.py diff --git a/winpython/wppm.py b/wppm/wppm.py similarity index 98% rename from winpython/wppm.py rename to wppm/wppm.py index 77a937ac..1f852236 100644 --- a/winpython/wppm.py +++ b/wppm/wppm.py @@ -1,406 +1,406 @@ -# -*- coding: utf-8 -*- -# -# WinPython Package Manager -# Copyright © 2012 Pierre Raybaut -# Copyright © 2014-2025+ The Winpython development team https://github.com/winpython/ -# Licensed under the terms of the MIT License -# (see winpython/__init__.py for details) - -import os -import re -import sys -import shutil -import subprocess -import json -from pathlib import Path -from argparse import ArgumentParser, RawTextHelpFormatter -from . import utils, piptree, associate, diff -from . import wheelhouse as wh -from operator import itemgetter -# Workaround for installing PyVISA on Windows from source: -os.environ["HOME"] = os.environ["USERPROFILE"] - -class Package: - """Standardize a Package from filename or pip list.""" - def __init__(self, fname: str, suggested_summary: str = None): - self.fname = fname - self.description = (utils.sum_up(suggested_summary) if suggested_summary else "").strip() - self.name, self.version = fname, '?.?.?' - if fname.lower().endswith((".zip", ".tar.gz", ".whl")): - bname = Path(self.fname).name # e.g., "sqlite_bro-1.0.0..." - infos = utils.get_source_package_infos(bname) # get name, version - if infos: - self.name, self.version = utils.normalize(infos[0]), infos[1] - self.url = f"https://pypi.org/project/{self.name}" - self.files = [] - - def __str__(self): - return f"{self.name} {self.version}\r\n{self.description}\r\nWebsite: {self.url}" - - -class Distribution: - """Handles operations on a WinPython distribution.""" - def __init__(self, target: str = None, verbose: bool = False): - self.target = target or str(Path(sys.executable).parent) # Default target more explicit - self.verbose = verbose - self.pip = None - self.to_be_removed = [] - self.version, self.architecture = utils.get_python_infos(self.target) - self.python_exe = utils.get_python_executable(self.target) - self.short_exe = Path(self.python_exe).name - self.wheelhouse = Path(self.target).parent / "wheelhouse" - - def create_file(self, package, name, dstdir, contents): - """Generate data file -- path is relative to distribution root dir""" - dst = Path(dstdir) / name - if self.verbose: - print(f"create: {dst}") - full_dst = Path(self.target) / dst - with open(full_dst, "w") as fd: - fd.write(contents) - package.files.append(str(dst)) - - def get_installed_packages(self, update: bool = False) -> list[Package]: - """Return installed packages.""" - if str(Path(sys.executable).parent) == self.target: - self.pip = piptree.PipData() - else: - self.pip = piptree.PipData(utils.get_python_executable(self.target)) - pip_list = self.pip.pip_list(full=True) - return [Package(f"{i[0].replace('-', '_').lower()}-{i[1]}-py3-none-any.whl", suggested_summary=i[2]) for i in pip_list] - - def render_markdown_for_list(self, title, items): - """Generates a Markdown section; name, url, version, summary""" - md = f"### {title}\n\n" - md += "Name | Version | Description\n" - md += "-----|---------|------------\n" - for name, url, version, summary in sorted(items, key=lambda p: (p[0].lower(), p[2])): - md += f"[{name}]({url}) | {version} | {summary}\n" - md += "\n" - return md - - def generate_package_index_markdown(self, python_executable_directory: str|None = None, winpyver2: str|None = None, - flavor: str|None = None, architecture_bits: int|None = None - , release_level: str|None = None, wheeldir: str|None = None) -> str: - """Generates a Markdown formatted package index page.""" - my_ver , my_arch = utils.get_python_infos(python_executable_directory or self.target) - my_winpyver2 = winpyver2 or os.getenv("WINPYVER2","") - my_winpyver2 = my_winpyver2 if my_winpyver2 != "" else my_ver - my_flavor = flavor or os.getenv("WINPYFLAVOR", "") - my_release_level = release_level or os.getenv("WINPYVER", "").replace(my_winpyver2+my_flavor, "") - - tools_list = utils.get_installed_tools(utils.get_python_executable(python_executable_directory)) - package_list = [(pkg.name, pkg.url, pkg.version, pkg.description) for pkg in self.get_installed_packages()] - wheelhouse_list = [] - my_wheeldir = Path(wheeldir) if wheeldir else self.wheelhouse / 'included.wheels' - if my_wheeldir.is_dir(): - wheelhouse_list = [(name, f"https://pypi.org/project/{name}", version, utils.sum_up(summary)) - for name, version, summary in wh.list_packages_with_metadata(str(my_wheeldir)) ] - - return f"""## WinPython {my_winpyver2 + my_flavor} - -The following packages are included in WinPython-{my_arch}bit v{my_winpyver2 + my_flavor} {my_release_level}. - -
- -{self.render_markdown_for_list("Tools", tools_list)} -{self.render_markdown_for_list("Python packages", package_list)} -{self.render_markdown_for_list("WheelHouse packages", wheelhouse_list)} -
-""" - - def find_package(self, name: str) -> Package | None: - """Find installed package by name.""" - for pack in self.get_installed_packages(): - if utils.normalize(pack.name) == utils.normalize(name): - return pack - - def patch_all_shebang(self, to_movable: bool = True, max_exe_size: int = 999999, targetdir: str = ""): - """Make all python launchers relative.""" - for ffname in Path(self.target).glob("Scripts/*.exe"): - if ffname.stat().st_size <= max_exe_size: - utils.patch_shebang_line(ffname, to_movable=to_movable, targetdir=targetdir) - for ffname in Path(self.target).glob("Scripts/*.py"): - utils.patch_shebang_line_py(ffname, to_movable=to_movable, targetdir=targetdir) - - def install(self, package: Package, install_options: list[str] = None): - """Install package in distribution.""" - if package.fname.endswith((".whl", ".tar.gz", ".zip")) or ( - ' ' not in package.fname and ';' not in package.fname and len(package.fname) >1): # Check extension with tuple - self.install_bdist_direct(package, install_options=install_options) - self.handle_specific_packages(package) - # minimal post-install actions - self.patch_standard_packages(package.name) - - def do_pip_action(self, actions: list[str] = None, install_options: list[str] = None): - """Execute pip action in the distribution.""" - my_list = install_options or [] - my_actions = actions or [] - executing = str(Path(self.target).parent / "scripts" / "env.bat") - if Path(executing).is_file(): - complement = [r"&&", "cd", "/D", self.target, r"&&", utils.get_python_executable(self.target), "-m", "pip"] - else: - executing = utils.get_python_executable(self.target) - complement = ["-m", "pip"] - try: - fname = utils.do_script(this_script=None, python_exe=executing, verbose=self.verbose, install_options=complement + my_actions + my_list) - except RuntimeError as e: - if not self.verbose: - print("Failed!") - raise - else: - print(f"Pip action failed with error: {e}") # Print error if verbose - - def patch_standard_packages(self, package_name="", to_movable=True): - """patch Winpython packages in need""" - import filecmp - - # 'pywin32' minimal post-install (pywin32_postinstall.py do too much) - if package_name.lower() in ("", "pywin32"): - origin = Path(self.target) / "site-packages" / "pywin32_system32" - destin = Path(self.target) - if origin.is_dir(): - for name in os.listdir(origin): - here, there = origin / name, destin / name - if not there.exists() or not filecmp.cmp(here, there): - shutil.copyfile(here, there) - # 'pip' to do movable launchers (around line 100) !!!! - # rational: https://github.com/pypa/pip/issues/2328 - if package_name.lower() == "pip" or package_name == "": - # ensure pip will create movable launchers - # sheb_mov1 = classic way up to WinPython 2016-01 - # sheb_mov2 = tried way, but doesn't work for pip (at least) - the_place = Path(self.target) / "lib" / "site-packages" / "pip" / "_vendor" / "distlib" / "scripts.py" - sheb_fix = " executable = get_executable()" - sheb_mov1 = " executable = os.path.join(os.path.basename(get_executable()))" - sheb_mov2 = " executable = os.path.join('..',os.path.basename(get_executable()))" - if to_movable: - utils.patch_sourcefile(the_place, sheb_fix, sheb_mov1) - utils.patch_sourcefile(the_place, sheb_mov2, sheb_mov1) - else: - utils.patch_sourcefile(the_place, sheb_mov1, sheb_fix) - utils.patch_sourcefile(the_place, sheb_mov2, sheb_fix) - - # create movable launchers for previous package installations - self.patch_all_shebang(to_movable=to_movable) - if package_name.lower() in ("", "spyder"): - # spyder don't goes on internet without you ask - utils.patch_sourcefile( - Path(self.target) / "lib" / "site-packages" / "spyder" / "config" / "main.py", - "'check_updates_on_startup': True,", - "'check_updates_on_startup': False,", - ) - - - def handle_specific_packages(self, package): - """Packages requiring additional configuration""" - if package.name.lower() in ("pyqt4", "pyqt5", "pyside2"): - # Qt configuration file (where to find Qt) - name = "qt.conf" - contents = """[Paths]\nPrefix = .\nBinaries = .""" - self.create_file(package, name, str(Path("Lib") / "site-packages" / package.name), contents) - self.create_file(package, name, ".", contents.replace(".", f"./Lib/site-packages/{package.name}")) - # pyuic script - if package.name.lower() == "pyqt5": - # see http://code.activestate.com/lists/python-list/666469/ - tmp_string = r"""@echo off -if "%WINPYDIR%"=="" call "%~dp0..\..\scripts\env.bat" -"%WINPYDIR%\python.exe" -m PyQt5.uic.pyuic %1 %2 %3 %4 %5 %6 %7 %8 %9""" - else: - tmp_string = r"""@echo off -if "%WINPYDIR%"=="" call "%~dp0..\..\scripts\env.bat" -"%WINPYDIR%\python.exe" "%WINPYDIR%\Lib\site-packages\package.name\uic\pyuic.py" %1 %2 %3 %4 %5 %6 %7 %8 %9""" - # PyPy adaption: python.exe or pypy3.exe - my_exec = Path(utils.get_python_executable(self.target)).name - tmp_string = tmp_string.replace("python.exe", my_exec).replace("package.name", package.name) - self.create_file(package, f"pyuic{package.name[-1]}.bat", "Scripts", tmp_string) - # Adding missing __init__.py files (fixes Issue 8) - uic_path = str(Path("Lib") / "site-packages" / package.name / "uic") - for dirname in ("Loader", "port_v2", "port_v3"): - self.create_file(package, "__init__.py", str(Path(uic_path) / dirname), "") - - def _print(self, package: Package, action: str): - """Print package-related action text.""" - text = f"{action} {package.name} {package.version}" - if self.verbose: - utils.print_box(text) - else: - print(f" {text}...", end=" ") - - def _print_done(self): - """Print OK at the end of a process""" - if not self.verbose: - print("OK") - - def uninstall(self, package): - """Uninstall package from distribution""" - self._print(package, "Uninstalling") - if package.name != "pip": - # trick to get true target (if not current) - this_exec = utils.get_python_executable(self.target) # PyPy ! - subprocess.call([this_exec, "-m", "pip", "uninstall", package.name, "-y"], cwd=self.target) - self._print_done() - - def install_bdist_direct(self, package, install_options=None): - """Install a package directly !""" - self._print(package,f"Installing {package.fname.split('.')[-1]}") - try: - fname = utils.direct_pip_install( - package.fname, - python_exe=utils.get_python_executable(self.target), # PyPy ! - verbose=self.verbose, - install_options=install_options, - ) - except RuntimeError: - if not self.verbose: - print("Failed!") - raise - package = Package(fname) - self._print_done() - -def main(test=False): - - registerWinPythonHelp = f"Register WinPython: associate file extensions, icons and context menu with this WinPython" - unregisterWinPythonHelp = f"Unregister WinPython: de-associate file extensions, icons and context menu from this WinPython" - parser = ArgumentParser( - description="WinPython Package Manager: handle a WinPython Distribution and its packages", - formatter_class=RawTextHelpFormatter, - ) - parser.add_argument("fname", metavar="package(s) or lockfile", nargs="*", default=[""], type=str, help="optional package names, wheels, or lockfile") - parser.add_argument("-v", "--verbose", action="store_true", help="show more details on packages and actions") - parser.add_argument( "--register", dest="registerWinPython", action="store_true", help=registerWinPythonHelp) - parser.add_argument("--unregister", dest="unregisterWinPython", action="store_true", help=unregisterWinPythonHelp) - parser.add_argument("--fix", action="store_true", help="make WinPython fix") - parser.add_argument("--movable", action="store_true", help="make WinPython movable") - parser.add_argument("-ws", dest="wheelsource", default=None, type=str, help="wheels location, '.' = WheelHouse): wppm pylock.toml -ws source_of_wheels, wppm -ls -ws .") - parser.add_argument("-wd", dest="wheeldrain" , default=None, type=str, help="wheels destination: wppm pylock.toml -wd destination_of_wheels") - parser.add_argument("-ls", "--list", action="store_true", help="list installed packages matching [optional] expression: wppm -ls, wppm -ls pand") - parser.add_argument("-lsa", dest="all", action="store_true",help=f"list details of packages matching [optional] expression: wppm -lsa pandas -l1") - parser.add_argument("-md", dest="markdown", action="store_true",help=f"markdown summary of the installation") - parser.add_argument("-p",dest="pipdown",action="store_true",help="show Package dependencies of the given package[option], [.]=all: wppm -p pandas[.]") - parser.add_argument("-r", dest="pipup", action="store_true", help=f"show Reverse (!= constraining) dependancies of the given package[option]: wppm -r pytest![test]") - parser.add_argument("-l", dest="levels", type=int, default=-1, help="show 'LEVELS' levels of dependencies (with -p, -r): wppm -p pandas -l1") - parser.add_argument("-t", dest="target", default=sys.prefix, help=f'path to target Python distribution (default: "{sys.prefix}")') - parser.add_argument("-i", "--install", action="store_true", help="install a given package wheel or pylock file (use pip for more features)") - parser.add_argument("-u", "--uninstall", action="store_true", help="uninstall package (use pip for more features)") - - args = parser.parse_args() - targetpython = None - if args.target and args.target != sys.prefix: - targetpython = args.target if args.target.lower().endswith('.exe') else str(Path(args.target) / 'python.exe') - if args.wheelsource == ".": # play in default WheelHouse - if utils.is_python_distribution(args.target): - dist = Distribution(args.target) - args.wheelsource = dist.wheelhouse / 'included.wheels' - if args.install and args.uninstall: - raise RuntimeError("Incompatible arguments: --install and --uninstall") - if args.registerWinPython and args.unregisterWinPython: - raise RuntimeError("Incompatible arguments: --install and --uninstall") - if args.pipdown: - pip = piptree.PipData(targetpython, args.wheelsource) - for args_fname in args.fname: - pack, extra, *other = (args_fname + "[").replace("]", "[").split("[") - print(pip.down(pack, extra, args.levels if args.levels>0 else 2, verbose=args.verbose)) - sys.exit() - elif args.pipup: - pip = piptree.PipData(targetpython, args.wheelsource) - for args_fname in args.fname: - pack, extra, *other = (args_fname + "[").replace("]", "[").split("[") - print(pip.up(pack, extra, args.levels if args.levels>=0 else 1, verbose=args.verbose)) - sys.exit() - elif args.list: - pip = piptree.PipData(targetpython, args.wheelsource) - todo= [] - for args_fname in args.fname: - todo += [l for l in pip.pip_list(full=True) if bool(re.search(args_fname, l[0]))] - todo = sorted(set(todo)) #, key=lambda p: (p[0].lower(), p[2]) - titles = [['Package', 'Version', 'Summary'], ['_' * max(x, 6) for x in utils.columns_width(todo)]] - listed = utils.formatted_list(titles + todo, max_width=70) - for p in listed: - print(*p) - sys.exit() - elif args.all: - pip = piptree.PipData(targetpython, args.wheelsource) - for args_fname in args.fname: - todo = [l for l in pip.pip_list(full=True) if bool(re.search(args_fname, l[0]))] - for l in sorted(set(todo)): - title = f"** Package: {l[0]} **" - print("\n" + "*" * len(title), f"\n{title}", "\n" + "*" * len(title)) - for key, value in pip.raw[l[0]].items(): - rawtext = json.dumps(value, indent=2, ensure_ascii=False) - lines = [l for l in rawtext.split(r"\n") if len(l.strip()) > 2] - if key.lower() != 'description' or args.verbose: - print(f"{key}: ", "\n".join(lines).replace('"', "")) - sys.exit() - if args.registerWinPython: - print(registerWinPythonHelp) - if utils.is_python_distribution(args.target): - dist = Distribution(args.target) - else: - raise OSError(f"Invalid Python distribution {args.target}") - print(f"registering {args.target}") - print("continue ? Y/N") - theAnswer = input() - if theAnswer == "Y": - associate.register(dist.target, verbose=args.verbose) - sys.exit() - if args.unregisterWinPython: - print(unregisterWinPythonHelp) - if utils.is_python_distribution(args.target): - dist = Distribution(args.target) - else: - raise OSError(f"Invalid Python distribution {args.target}") - print(f"unregistering {args.target}") - print("continue ? Y/N") - theAnswer = input() - if theAnswer == "Y": - associate.unregister(dist.target, verbose=args.verbose) - sys.exit() - if utils.is_python_distribution(args.target): - dist = Distribution(args.target, verbose=True) - cmd_fix = rf"from winpython import wppm;dist=wppm.Distribution(r'{dist.target}');dist.patch_standard_packages('pip', to_movable=False)" - cmd_mov = rf"from winpython import wppm;dist=wppm.Distribution(r'{dist.target}');dist.patch_standard_packages('pip', to_movable=True)" - if args.fix: - # dist.patch_standard_packages('pip', to_movable=False) # would fail on wppm.exe - p = subprocess.Popen(["start", "cmd", "/k",dist.python_exe, "-c" , cmd_fix], shell = True, cwd=dist.target) - sys.exit() - if args.movable: - p = subprocess.Popen(["start", "cmd", "/k",dist.python_exe, "-c" , cmd_mov], shell = True, cwd=dist.target) - sys.exit() - if args.markdown: - default = dist.generate_package_index_markdown() - if args.wheelsource: - compare = dist.generate_package_index_markdown(wheeldir = args.wheelsource) - print(diff.compare_markdown_sections(default, compare,'python', 'wheelhouse', 'installed', 'wheelhouse')) - else: - print(default) - sys.exit() - if not args.install and not args.uninstall and args.fname[0].endswith(".toml"): - args.install = True # for Drag & Drop of .toml (and not wheel) - if args.fname[0] == "" or (not args.install and not args.uninstall): - parser.print_help() - sys.exit() - else: - try: - for args_fname in args.fname: - filename = Path(args_fname).name - install_from_wheelhouse = ["--no-index", "--trusted-host=None", f"--find-links={dist.wheelhouse / 'included.wheels'}"] - if filename.split('.')[0] == "pylock" and filename.split('.')[-1] == 'toml': - print(' a lock file !', args_fname, dist.target) - wh.get_pylock_wheels(dist.wheelhouse, Path(args_fname), args.wheelsource, args.wheeldrain) - sys.exit() - if args.uninstall: - package = dist.find_package(args_fname) - dist.uninstall(package) - elif args.install: - package = Package(args_fname) - if args.install: - dist.install(package, install_options=install_from_wheelhouse) - except NotImplementedError: - raise RuntimeError("Package is not (yet) supported by WPPM") - else: - raise OSError(f"Invalid Python distribution {args.target}") - - -if __name__ == "__main__": +# -*- coding: utf-8 -*- +# +# WinPython Package Manager +# Copyright © 2012 Pierre Raybaut +# Copyright © 2014-2025+ The Winpython development team https://github.com/winpython/ +# Licensed under the terms of the MIT License +# (see winpython/__init__.py for details) + +import os +import re +import sys +import shutil +import subprocess +import json +from pathlib import Path +from argparse import ArgumentParser, RawTextHelpFormatter +from . import utils, piptree, associate, diff +from . import wheelhouse as wh +from operator import itemgetter +# Workaround for installing PyVISA on Windows from source: +os.environ["HOME"] = os.environ["USERPROFILE"] + +class Package: + """Standardize a Package from filename or pip list.""" + def __init__(self, fname: str, suggested_summary: str = None): + self.fname = fname + self.description = (utils.sum_up(suggested_summary) if suggested_summary else "").strip() + self.name, self.version = fname, '?.?.?' + if fname.lower().endswith((".zip", ".tar.gz", ".whl")): + bname = Path(self.fname).name # e.g., "sqlite_bro-1.0.0..." + infos = utils.get_source_package_infos(bname) # get name, version + if infos: + self.name, self.version = utils.normalize(infos[0]), infos[1] + self.url = f"https://pypi.org/project/{self.name}" + self.files = [] + + def __str__(self): + return f"{self.name} {self.version}\r\n{self.description}\r\nWebsite: {self.url}" + + +class Distribution: + """Handles operations on a WinPython distribution.""" + def __init__(self, target: str = None, verbose: bool = False): + self.target = target or str(Path(sys.executable).parent) # Default target more explicit + self.verbose = verbose + self.pip = None + self.to_be_removed = [] + self.version, self.architecture = utils.get_python_infos(self.target) + self.python_exe = utils.get_python_executable(self.target) + self.short_exe = Path(self.python_exe).name + self.wheelhouse = Path(self.target).parent / "wheelhouse" + + def create_file(self, package, name, dstdir, contents): + """Generate data file -- path is relative to distribution root dir""" + dst = Path(dstdir) / name + if self.verbose: + print(f"create: {dst}") + full_dst = Path(self.target) / dst + with open(full_dst, "w") as fd: + fd.write(contents) + package.files.append(str(dst)) + + def get_installed_packages(self, update: bool = False) -> list[Package]: + """Return installed packages.""" + if str(Path(sys.executable).parent) == self.target: + self.pip = piptree.PipData() + else: + self.pip = piptree.PipData(utils.get_python_executable(self.target)) + pip_list = self.pip.pip_list(full=True) + return [Package(f"{i[0].replace('-', '_').lower()}-{i[1]}-py3-none-any.whl", suggested_summary=i[2]) for i in pip_list] + + def render_markdown_for_list(self, title, items): + """Generates a Markdown section; name, url, version, summary""" + md = f"### {title}\n\n" + md += "Name | Version | Description\n" + md += "-----|---------|------------\n" + for name, url, version, summary in sorted(items, key=lambda p: (p[0].lower(), p[2])): + md += f"[{name}]({url}) | {version} | {summary}\n" + md += "\n" + return md + + def generate_package_index_markdown(self, python_executable_directory: str|None = None, winpyver2: str|None = None, + flavor: str|None = None, architecture_bits: int|None = None + , release_level: str|None = None, wheeldir: str|None = None) -> str: + """Generates a Markdown formatted package index page.""" + my_ver , my_arch = utils.get_python_infos(python_executable_directory or self.target) + my_winpyver2 = winpyver2 or os.getenv("WINPYVER2","") + my_winpyver2 = my_winpyver2 if my_winpyver2 != "" else my_ver + my_flavor = flavor or os.getenv("WINPYFLAVOR", "") + my_release_level = release_level or os.getenv("WINPYVER", "").replace(my_winpyver2+my_flavor, "") + + tools_list = utils.get_installed_tools(utils.get_python_executable(python_executable_directory)) + package_list = [(pkg.name, pkg.url, pkg.version, pkg.description) for pkg in self.get_installed_packages()] + wheelhouse_list = [] + my_wheeldir = Path(wheeldir) if wheeldir else self.wheelhouse / 'included.wheels' + if my_wheeldir.is_dir(): + wheelhouse_list = [(name, f"https://pypi.org/project/{name}", version, utils.sum_up(summary)) + for name, version, summary in wh.list_packages_with_metadata(str(my_wheeldir)) ] + + return f"""## WinPython {my_winpyver2 + my_flavor} + +The following packages are included in WinPython-{my_arch}bit v{my_winpyver2 + my_flavor} {my_release_level}. + +
+ +{self.render_markdown_for_list("Tools", tools_list)} +{self.render_markdown_for_list("Python packages", package_list)} +{self.render_markdown_for_list("WheelHouse packages", wheelhouse_list)} +
+""" + + def find_package(self, name: str) -> Package | None: + """Find installed package by name.""" + for pack in self.get_installed_packages(): + if utils.normalize(pack.name) == utils.normalize(name): + return pack + + def patch_all_shebang(self, to_movable: bool = True, max_exe_size: int = 999999, targetdir: str = ""): + """Make all python launchers relative.""" + for ffname in Path(self.target).glob("Scripts/*.exe"): + if ffname.stat().st_size <= max_exe_size: + utils.patch_shebang_line(ffname, to_movable=to_movable, targetdir=targetdir) + for ffname in Path(self.target).glob("Scripts/*.py"): + utils.patch_shebang_line_py(ffname, to_movable=to_movable, targetdir=targetdir) + + def install(self, package: Package, install_options: list[str] = None): + """Install package in distribution.""" + if package.fname.endswith((".whl", ".tar.gz", ".zip")) or ( + ' ' not in package.fname and ';' not in package.fname and len(package.fname) >1): # Check extension with tuple + self.install_bdist_direct(package, install_options=install_options) + self.handle_specific_packages(package) + # minimal post-install actions + self.patch_standard_packages(package.name) + + def do_pip_action(self, actions: list[str] = None, install_options: list[str] = None): + """Execute pip action in the distribution.""" + my_list = install_options or [] + my_actions = actions or [] + executing = str(Path(self.target).parent / "scripts" / "env.bat") + if Path(executing).is_file(): + complement = [r"&&", "cd", "/D", self.target, r"&&", utils.get_python_executable(self.target), "-m", "pip"] + else: + executing = utils.get_python_executable(self.target) + complement = ["-m", "pip"] + try: + fname = utils.do_script(this_script=None, python_exe=executing, verbose=self.verbose, install_options=complement + my_actions + my_list) + except RuntimeError as e: + if not self.verbose: + print("Failed!") + raise + else: + print(f"Pip action failed with error: {e}") # Print error if verbose + + def patch_standard_packages(self, package_name="", to_movable=True): + """patch Winpython packages in need""" + import filecmp + + # 'pywin32' minimal post-install (pywin32_postinstall.py do too much) + if package_name.lower() in ("", "pywin32"): + origin = Path(self.target) / "site-packages" / "pywin32_system32" + destin = Path(self.target) + if origin.is_dir(): + for name in os.listdir(origin): + here, there = origin / name, destin / name + if not there.exists() or not filecmp.cmp(here, there): + shutil.copyfile(here, there) + # 'pip' to do movable launchers (around line 100) !!!! + # rational: https://github.com/pypa/pip/issues/2328 + if package_name.lower() == "pip" or package_name == "": + # ensure pip will create movable launchers + # sheb_mov1 = classic way up to WinPython 2016-01 + # sheb_mov2 = tried way, but doesn't work for pip (at least) + the_place = Path(self.target) / "lib" / "site-packages" / "pip" / "_vendor" / "distlib" / "scripts.py" + sheb_fix = " executable = get_executable()" + sheb_mov1 = " executable = os.path.join(os.path.basename(get_executable()))" + sheb_mov2 = " executable = os.path.join('..',os.path.basename(get_executable()))" + if to_movable: + utils.patch_sourcefile(the_place, sheb_fix, sheb_mov1) + utils.patch_sourcefile(the_place, sheb_mov2, sheb_mov1) + else: + utils.patch_sourcefile(the_place, sheb_mov1, sheb_fix) + utils.patch_sourcefile(the_place, sheb_mov2, sheb_fix) + + # create movable launchers for previous package installations + self.patch_all_shebang(to_movable=to_movable) + if package_name.lower() in ("", "spyder"): + # spyder don't goes on internet without you ask + utils.patch_sourcefile( + Path(self.target) / "lib" / "site-packages" / "spyder" / "config" / "main.py", + "'check_updates_on_startup': True,", + "'check_updates_on_startup': False,", + ) + + + def handle_specific_packages(self, package): + """Packages requiring additional configuration""" + if package.name.lower() in ("pyqt4", "pyqt5", "pyside2"): + # Qt configuration file (where to find Qt) + name = "qt.conf" + contents = """[Paths]\nPrefix = .\nBinaries = .""" + self.create_file(package, name, str(Path("Lib") / "site-packages" / package.name), contents) + self.create_file(package, name, ".", contents.replace(".", f"./Lib/site-packages/{package.name}")) + # pyuic script + if package.name.lower() == "pyqt5": + # see http://code.activestate.com/lists/python-list/666469/ + tmp_string = r"""@echo off +if "%WINPYDIR%"=="" call "%~dp0..\..\scripts\env.bat" +"%WINPYDIR%\python.exe" -m PyQt5.uic.pyuic %1 %2 %3 %4 %5 %6 %7 %8 %9""" + else: + tmp_string = r"""@echo off +if "%WINPYDIR%"=="" call "%~dp0..\..\scripts\env.bat" +"%WINPYDIR%\python.exe" "%WINPYDIR%\Lib\site-packages\package.name\uic\pyuic.py" %1 %2 %3 %4 %5 %6 %7 %8 %9""" + # PyPy adaption: python.exe or pypy3.exe + my_exec = Path(utils.get_python_executable(self.target)).name + tmp_string = tmp_string.replace("python.exe", my_exec).replace("package.name", package.name) + self.create_file(package, f"pyuic{package.name[-1]}.bat", "Scripts", tmp_string) + # Adding missing __init__.py files (fixes Issue 8) + uic_path = str(Path("Lib") / "site-packages" / package.name / "uic") + for dirname in ("Loader", "port_v2", "port_v3"): + self.create_file(package, "__init__.py", str(Path(uic_path) / dirname), "") + + def _print(self, package: Package, action: str): + """Print package-related action text.""" + text = f"{action} {package.name} {package.version}" + if self.verbose: + utils.print_box(text) + else: + print(f" {text}...", end=" ") + + def _print_done(self): + """Print OK at the end of a process""" + if not self.verbose: + print("OK") + + def uninstall(self, package): + """Uninstall package from distribution""" + self._print(package, "Uninstalling") + if package.name != "pip": + # trick to get true target (if not current) + this_exec = utils.get_python_executable(self.target) # PyPy ! + subprocess.call([this_exec, "-m", "pip", "uninstall", package.name, "-y"], cwd=self.target) + self._print_done() + + def install_bdist_direct(self, package, install_options=None): + """Install a package directly !""" + self._print(package,f"Installing {package.fname.split('.')[-1]}") + try: + fname = utils.direct_pip_install( + package.fname, + python_exe=utils.get_python_executable(self.target), # PyPy ! + verbose=self.verbose, + install_options=install_options, + ) + except RuntimeError: + if not self.verbose: + print("Failed!") + raise + package = Package(fname) + self._print_done() + +def main(test=False): + + registerWinPythonHelp = f"Register WinPython: associate file extensions, icons and context menu with this WinPython" + unregisterWinPythonHelp = f"Unregister WinPython: de-associate file extensions, icons and context menu from this WinPython" + parser = ArgumentParser(prog="wppm", + description="WinPython Package Manager: handle a WinPython Distribution and its packages", + formatter_class=RawTextHelpFormatter, + ) + parser.add_argument("fname", metavar="package(s) or lockfile", nargs="*", default=[""], type=str, help="optional package names, wheels, or lockfile") + parser.add_argument("-v", "--verbose", action="store_true", help="show more details on packages and actions") + parser.add_argument( "--register", dest="registerWinPython", action="store_true", help=registerWinPythonHelp) + parser.add_argument("--unregister", dest="unregisterWinPython", action="store_true", help=unregisterWinPythonHelp) + parser.add_argument("--fix", action="store_true", help="make WinPython fix") + parser.add_argument("--movable", action="store_true", help="make WinPython movable") + parser.add_argument("-ws", dest="wheelsource", default=None, type=str, help="wheels location, '.' = WheelHouse): wppm pylock.toml -ws source_of_wheels, wppm -ls -ws .") + parser.add_argument("-wd", dest="wheeldrain" , default=None, type=str, help="wheels destination: wppm pylock.toml -wd destination_of_wheels") + parser.add_argument("-ls", "--list", action="store_true", help="list installed packages matching [optional] expression: wppm -ls, wppm -ls pand") + parser.add_argument("-lsa", dest="all", action="store_true",help=f"list details of packages matching [optional] expression: wppm -lsa pandas -l1") + parser.add_argument("-md", dest="markdown", action="store_true",help=f"markdown summary of the installation") + parser.add_argument("-p",dest="pipdown",action="store_true",help="show Package dependencies of the given package[option], [.]=all: wppm -p pandas[.]") + parser.add_argument("-r", dest="pipup", action="store_true", help=f"show Reverse (!= constraining) dependancies of the given package[option]: wppm -r pytest![test]") + parser.add_argument("-l", dest="levels", type=int, default=-1, help="show 'LEVELS' levels of dependencies (with -p, -r): wppm -p pandas -l1") + parser.add_argument("-t", dest="target", default=sys.prefix, help=f'path to target Python distribution (default: "{sys.prefix}")') + parser.add_argument("-i", "--install", action="store_true", help="install a given package wheel or pylock file (use pip for more features)") + parser.add_argument("-u", "--uninstall", action="store_true", help="uninstall package (use pip for more features)") + + args = parser.parse_args() + targetpython = None + if args.target and args.target != sys.prefix: + targetpython = args.target if args.target.lower().endswith('.exe') else str(Path(args.target) / 'python.exe') + if args.wheelsource == ".": # play in default WheelHouse + if utils.is_python_distribution(args.target): + dist = Distribution(args.target) + args.wheelsource = dist.wheelhouse / 'included.wheels' + if args.install and args.uninstall: + raise RuntimeError("Incompatible arguments: --install and --uninstall") + if args.registerWinPython and args.unregisterWinPython: + raise RuntimeError("Incompatible arguments: --install and --uninstall") + if args.pipdown: + pip = piptree.PipData(targetpython, args.wheelsource) + for args_fname in args.fname: + pack, extra, *other = (args_fname + "[").replace("]", "[").split("[") + print(pip.down(pack, extra, args.levels if args.levels>0 else 2, verbose=args.verbose)) + sys.exit() + elif args.pipup: + pip = piptree.PipData(targetpython, args.wheelsource) + for args_fname in args.fname: + pack, extra, *other = (args_fname + "[").replace("]", "[").split("[") + print(pip.up(pack, extra, args.levels if args.levels>=0 else 1, verbose=args.verbose)) + sys.exit() + elif args.list: + pip = piptree.PipData(targetpython, args.wheelsource) + todo= [] + for args_fname in args.fname: + todo += [l for l in pip.pip_list(full=True) if bool(re.search(args_fname, l[0]))] + todo = sorted(set(todo)) #, key=lambda p: (p[0].lower(), p[2]) + titles = [['Package', 'Version', 'Summary'], ['_' * max(x, 6) for x in utils.columns_width(todo)]] + listed = utils.formatted_list(titles + todo, max_width=70) + for p in listed: + print(*p) + sys.exit() + elif args.all: + pip = piptree.PipData(targetpython, args.wheelsource) + for args_fname in args.fname: + todo = [l for l in pip.pip_list(full=True) if bool(re.search(args_fname, l[0]))] + for l in sorted(set(todo)): + title = f"** Package: {l[0]} **" + print("\n" + "*" * len(title), f"\n{title}", "\n" + "*" * len(title)) + for key, value in pip.raw[l[0]].items(): + rawtext = json.dumps(value, indent=2, ensure_ascii=False) + lines = [l for l in rawtext.split(r"\n") if len(l.strip()) > 2] + if key.lower() != 'description' or args.verbose: + print(f"{key}: ", "\n".join(lines).replace('"', "")) + sys.exit() + if args.registerWinPython: + print(registerWinPythonHelp) + if utils.is_python_distribution(args.target): + dist = Distribution(args.target) + else: + raise OSError(f"Invalid Python distribution {args.target}") + print(f"registering {args.target}") + print("continue ? Y/N") + theAnswer = input() + if theAnswer == "Y": + associate.register(dist.target, verbose=args.verbose) + sys.exit() + if args.unregisterWinPython: + print(unregisterWinPythonHelp) + if utils.is_python_distribution(args.target): + dist = Distribution(args.target) + else: + raise OSError(f"Invalid Python distribution {args.target}") + print(f"unregistering {args.target}") + print("continue ? Y/N") + theAnswer = input() + if theAnswer == "Y": + associate.unregister(dist.target, verbose=args.verbose) + sys.exit() + if utils.is_python_distribution(args.target): + dist = Distribution(args.target, verbose=True) + cmd_fix = rf"from winpython import wppm;dist=wppm.Distribution(r'{dist.target}');dist.patch_standard_packages('pip', to_movable=False)" + cmd_mov = rf"from winpython import wppm;dist=wppm.Distribution(r'{dist.target}');dist.patch_standard_packages('pip', to_movable=True)" + if args.fix: + # dist.patch_standard_packages('pip', to_movable=False) # would fail on wppm.exe + p = subprocess.Popen(["start", "cmd", "/k",dist.python_exe, "-c" , cmd_fix], shell = True, cwd=dist.target) + sys.exit() + if args.movable: + p = subprocess.Popen(["start", "cmd", "/k",dist.python_exe, "-c" , cmd_mov], shell = True, cwd=dist.target) + sys.exit() + if args.markdown: + default = dist.generate_package_index_markdown() + if args.wheelsource: + compare = dist.generate_package_index_markdown(wheeldir = args.wheelsource) + print(diff.compare_markdown_sections(default, compare,'python', 'wheelhouse', 'installed', 'wheelhouse')) + else: + print(default) + sys.exit() + if not args.install and not args.uninstall and args.fname[0].endswith(".toml"): + args.install = True # for Drag & Drop of .toml (and not wheel) + if args.fname[0] == "" or (not args.install and not args.uninstall): + parser.print_help() + sys.exit() + else: + try: + for args_fname in args.fname: + filename = Path(args_fname).name + install_from_wheelhouse = ["--no-index", "--trusted-host=None", f"--find-links={dist.wheelhouse / 'included.wheels'}"] + if filename.split('.')[0] == "pylock" and filename.split('.')[-1] == 'toml': + print(' a lock file !', args_fname, dist.target) + wh.get_pylock_wheels(dist.wheelhouse, Path(args_fname), args.wheelsource, args.wheeldrain) + sys.exit() + if args.uninstall: + package = dist.find_package(args_fname) + dist.uninstall(package) + elif args.install: + package = Package(args_fname) + if args.install: + dist.install(package, install_options=install_from_wheelhouse) + except NotImplementedError: + raise RuntimeError("Package is not (yet) supported by WPPM") + else: + raise OSError(f"Invalid Python distribution {args.target}") + + +if __name__ == "__main__": main() \ No newline at end of file