diff --git a/diff.py b/diff.py deleted file mode 100644 index 1fd01586..00000000 --- a/diff.py +++ /dev/null @@ -1,168 +0,0 @@ -# -*- coding: utf-8 -*- -# -# WinPython diff.py script -# Copyright © 2013 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 -from pathlib import Path -import re -import shutil -from packaging import version -from winpython import utils - -CHANGELOGS_DIR = Path(__file__).parent / "changelogs" -assert CHANGELOGS_DIR.is_dir() - -class Package: - # SourceForge Wiki syntax: - PATTERN = r"\[([a-zA-Z\-\:\/\.\_0-9]*)\]\(([^\]\ ]*)\) \| ([^\|]*) \| ([^\|]*)" - # Google Code Wiki syntax: - PATTERN_OLD = r"\[([a-zA-Z\-\:\/\.\_0-9]*) ([^\]\ ]*)\] \| ([^\|]*) \| ([^\|]*)" - - def __init__(self): - self.name = self.version = self.description = self.url = None - - def __str__(self): - return f"{self.name} {self.version}\r\n{self.description}\r\nWebsite: {self.url}" - - def from_text(self, text): - match = re.match(self.PATTERN_OLD, text) or re.match(self.PATTERN, text) - if not match: - raise ValueError("Text does not match expected pattern") - self.name, self.url, self.version, self.description = match.groups() - - def to_wiki(self): - return f" * [{self.name}]({self.url}) {self.version} ({self.description})\r\n" - - def upgrade_wiki(self, other): - assert self.name.replace("-", "_").lower() == other.name.replace("-", "_").lower() - return f" * [{self.name}]({self.url}) {other.version} → {self.version} ({self.description})\r\n" - -class PackageIndex: - WINPYTHON_PATTERN = r"\#\# WinPython\-*[0-9b-t]* ([0-9\.a-zA-Z]*)" - TOOLS_LINE = "### Tools" - PYTHON_PACKAGES_LINE = "### Python packages" - HEADER_LINE1 = "Name | Version | Description" - HEADER_LINE2 = "-----|---------|------------" - - def __init__(self, version, basedir=None, flavor="", architecture=64): - self.version = version - self.flavor = flavor - self.basedir = basedir - self.architecture = architecture - self.other_packages = {} - self.python_packages = {} - self.from_file(basedir) - - def from_file(self, basedir): - fname = CHANGELOGS_DIR / f"WinPython{self.flavor}-{self.architecture}bit-{self.version}.md" - if not fname.exists(): - raise FileNotFoundError(f"Changelog file not found: {fname}") - with open(fname, "r", encoding=utils.guess_encoding(fname)[0]) as fdesc: - self.from_text(fdesc.read()) - - def from_text(self, text): - version = re.match(self.WINPYTHON_PATTERN + self.flavor, text).groups()[0] - assert version == self.version - tools_flag = python_flag = False - for line in text.splitlines(): - if line: - if line == self.TOOLS_LINE: - tools_flag, python_flag = True, False - continue - elif line == self.PYTHON_PACKAGES_LINE: - tools_flag, python_flag = False, True - continue - elif line in (self.HEADER_LINE1, self.HEADER_LINE2, "
", "
"): - continue - if tools_flag or python_flag: - package = Package() - package.from_text(line) - if tools_flag: - self.other_packages[package.name] = package - else: - self.python_packages[package.name] = package - -def diff_package_dicts(old_packages, new_packages): - """Return difference between package old and package new""" - - # wheel replace '-' per '_' in key - old = {k.replace("-", "_").lower(): v for k, v in old_packages.items()} - new = {k.replace("-", "_").lower(): v for k, v in new_packages.items()} - text = "" - - if new_keys := sorted(set(new) - set(old)): - text += "New packages:\r\n\r\n" + "".join(new[k].to_wiki() for k in new_keys) + "\r\n" - - if upgraded := [new[k].upgrade_wiki(old[k]) for k in sorted(set(old) & set(new)) if old[k].version != new[k].version]: - text += "Upgraded packages:\r\n\r\n" + f"{''.join(upgraded)}" + "\r\n" - - if removed_keys := sorted(set(old) - set(new)): - text += "Removed packages:\r\n\r\n" + "".join(old[k].to_wiki() for k in removed_keys) + "\r\n" - return text - -def find_closer_version(version1, basedir=None, flavor="", architecture=64): - """Find version which is the closest to `version`""" - builddir = Path(basedir) / f"bu{flavor}" - pattern = re.compile(rf"WinPython{flavor}-{architecture}bit-([0-9\.]*)\.(txt|md)") - versions = [pattern.match(name).groups()[0] for name in os.listdir(builddir) if pattern.match(name)] - - if version1 not in versions: - raise ValueError(f"Unknown version {version1}") - - version_below = '0.0.0.0' - for v in versions: - if version.parse(version_below) < version.parse(v) and version.parse(v) < version.parse(version1): - version_below = v - - return version_below if version_below != '0.0.0.0' else version1 - -def compare_package_indexes(version2, version1=None, basedir=None, flavor="", flavor1=None,architecture=64): - """Compare two package index Wiki pages""" - version1 = version1 if version1 else find_closer_version(version2, basedir, flavor, architecture) - flavor1 = flavor1 if flavor1 else flavor - pi1 = PackageIndex(version1, basedir, flavor1, architecture) - pi2 = PackageIndex(version2, basedir, flavor, architecture) - - text = ( - f"## History of changes for WinPython-{architecture}bit {version2 + flavor}\r\n\r\n" - f"The following changes were made to WinPython-{architecture}bit distribution since version {version1 + flavor1}.\r\n\r\n" - "
\r\n\r\n" - ) - - tools_text = diff_package_dicts(pi1.other_packages, pi2.other_packages) - if tools_text: - text += PackageIndex.TOOLS_LINE + "\r\n\r\n" + tools_text - - py_text = diff_package_dicts(pi1.python_packages, pi2.python_packages) - if py_text: - text += PackageIndex.PYTHON_PACKAGES_LINE + "\r\n\r\n" + py_text - - text += "\r\n
\r\n* * *\r\n" - return text - -def _copy_all_changelogs(version, basedir, flavor="", architecture=64): - basever = ".".join(version.split(".")[:2]) - pattern = re.compile(rf"WinPython{flavor}-{architecture}bit-{basever}([0-9\.]*)\.(txt|md)") - for name in os.listdir(CHANGELOGS_DIR): - if pattern.match(name): - shutil.copyfile(CHANGELOGS_DIR / name, Path(basedir) / f"bu{flavor}" / name) - -def write_changelog(version2, version1=None, basedir=None, flavor="", architecture=64): - """Write changelog between version1 and version2 of WinPython""" - _copy_all_changelogs(version2, basedir, flavor, architecture) - print("comparing_package_indexes", version2, basedir, flavor, architecture) - changelog_text = compare_package_indexes(version2, version1, basedir, flavor, architecture=architecture) - output_file = Path(basedir) / f"bu{flavor}" / f"WinPython{flavor}-{architecture}bit-{version2}_History.md" - - with open(output_file, "w", encoding="utf-8") as fdesc: - fdesc.write(changelog_text) - # Copy to winpython/changelogs - shutil.copyfile(output_file, CHANGELOGS_DIR / output_file.name) - -if __name__ == "__main__": - print(compare_package_indexes("3.7.4.0", "3.7.2.0", r"C:\WinP\bd37", "Zero", architecture=32)) - write_changelog("3.7.4.0", "3.7.2.0", r"C:\WinP\bd37", "Ps2", architecture=64) diff --git a/generate_a_winpython_distro.bat b/generate_a_winpython_distro.bat index 7fdbc12f..e9c73d9a 100644 --- a/generate_a_winpython_distro.bat +++ b/generate_a_winpython_distro.bat @@ -2,7 +2,7 @@ rem generate_a_winpython_distro.bat: to be launched from a winpython directory, @echo on REM Initialize variables -if "%my_release_level%"=="" set my_release_level=b3 +if "%my_release_level%"=="" set my_release_level=b4 if "%my_create_installer%"=="" set my_create_installer=True rem Set archive directory and log file @@ -26,8 +26,8 @@ if "%target_python_exe%"=="" set target_python_exe=python.exe rem Set Python target release based on my_python_target if %my_python_target%==311 set my_python_target_release=3119& set my_release=1 -if %my_python_target%==312 set my_python_target_release=31210& set my_release=0 -if %my_python_target%==313 set my_python_target_release=3133& set my_release=0 +if %my_python_target%==312 set my_python_target_release=31210& set my_release=1 +if %my_python_target%==313 set my_python_target_release=3133& set my_release=1 if %my_python_target%==314 set my_python_target_release=3140& set my_release=0 echo -------------------------------------- >>%my_archive_log% @@ -101,10 +101,102 @@ echo -------------------------------------- >>%my_archive_log% python -m pip install -r %my_requirements% -c %my_constraints% --pre --no-index --trusted-host=None --find-links=%my_find_links% >>%my_archive_log% python -c "from winpython import wppm;dist=wppm.Distribution(r'%WINPYDIR%');dist.patch_standard_packages('spyder', to_movable=True)" +REM Add Wheelhouse (to replace per pip lock direct ? would allow paralellism) +echo -------------------------------------- >>%my_archive_log% +echo "(%date% %time%) Add lockfile wheels">>%my_archive_log% +echo -------------------------------------- >>%my_archive_log% +set path=%my_original_path% +@echo on +call %my_WINPYDIRBASE%\scripts\env.bat +@echo on +set WINPYVERLOCK=%WINPYVER2:.=_% +set pylockinclude=%my_root_dir_for_builds%\bd%my_python_target%\bu%addlockfile%\pylock.%addlockfile%-%WINPYARCH%bit-%WINPYVERLOCK%.toml +echo pylockinclude="%pylockinclude%" +if not "Z%addlockfile%Z"=="ZZ" if exist "%pylockinclude%" ( +echo %my_WINPYDIRBASE%\python\scripts\wppm.exe "%pylockinclude%" -ws "%my_find_links%" -wd "%my_WINPYDIRBASE%\wheelhouse\included.wheels">>%my_archive_log% +%my_WINPYDIRBASE%\python\scripts\wppm.exe "%pylockinclude%" -ws "%my_find_links%" -wd "%my_WINPYDIRBASE%\wheelhouse\included.wheels" +) + +@echo on +echo wheelhousereq=%wheelhousereq% +set LOCKDIR=%WINPYDIRBASE%\..\ +set pip_lock_includedlocal=%LOCKDIR%pylock.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_includedwheelslocal.toml +set pip_lock_includedweb=%LOCKDIR%pylock.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_includedwheels.toml +set req_lock_includedlocal=%LOCKDIR%requirement.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_includedwheelslocal.txt +set req_lock_includedweb=%LOCKDIR%requirement.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_includedwheels.txt + +if not "Z%wheelhousereq%Z"=="ZZ" if exist "%wheelhousereq%" ( +echo JOYYYwheelhousereq=%wheelhousereq% +echo z%pip_lock_includedlocal%z=%pip_lock_includedlocal% +rem no winpython in it naturally, with deps +python.exe -m pip lock --no-index --trusted-host=None --find-links=%my_find_links% -c C:\WinP\constraints.txt -r "%wheelhousereq%" -o %pip_lock_includedlocal% +rem generating also classic requirement with hash-256, from obtained pylock.toml +python.exe -c "from winpython import wheelhouse as wh;wh.pylock_to_req(r'%pip_lock_includedlocal%', r'%req_lock_includedlocal%')" + +rem same with frozen web from local +python.exe -m pip lock --no-deps --require-hashes -c C:\WinP\constraints.txt -r "%req_lock_includedlocal%" -o %pip_lock_includedweb% + +echo %my_WINPYDIRBASE%\python\scripts\wppm.exe "%pip_lock_includedweb%" -ws "%my_find_links%" -wd "%my_WINPYDIRBASE%\wheelhouse\included.wheels">>%my_archive_log% +%my_WINPYDIRBASE%\python\scripts\wppm.exe "%pip_lock_includedweb%" -ws "%my_find_links%" -wd "%my_WINPYDIRBASE%\wheelhouse\included.wheels" +) + +echo -------------------------------------- >>%my_archive_log%; +echo "(%date% %time%) generate pylock.toml files and requirement.txt with hash files">>%my_archive_log% +echo -------------------------------------- >>%my_archive_log% + +set path=%my_original_path% +call %my_WINPYDIRBASE%\scripts\env.bat + +rem generate pip freeze requirements +echo %date% %time% +set LOCKDIR=%WINPYDIRBASE%\..\ + +set WINPYVERLOCK=%WINPYVER2:.=_% +set req=%LOCKDIR%requirement.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_raw.txt +set wanted_req=%LOCKDIR%requirement.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%.txt +set pip_lock_web=%LOCKDIR%pylock.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%.toml +set pip_lock_local=%LOCKDIR%pylock.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_local.toml +set req_lock_web=%LOCKDIR%requirement.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%.txt +set req_lock_local=%LOCKDIR%requirement.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_local.txt + +set pip_lock_web=%LOCKDIR%pylock.%WINPYARCH%-%WINPYVERLOCK%%my_flavor%%my_release_level%.toml +set pip_lock_local=%LOCKDIR%pylock.%WINPYARCH%-%WINPYVERLOCK%%my_flavor%%my_release_level%_local.toml +set req_lock_web=%LOCKDIR%requirement.%WINPYARCH%-%WINPYVERLOCK%%my_flavor%%my_release_level%.txt +set req_lock_local=%LOCKDIR%requirement.%WINPYARCH%-%WINPYVERLOCK%%my_flavor%_local.txt + + +set my_archive_lockfile=%my_archive_dir%\pylock.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_%date:/=-%at_%my_time%.toml +set my_archive_lockfile_local=%my_archive_dir%\pylock.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%_%date:/=-%at_%my_time%.local.toml +set my_changelog_lockfile=%~dp0changelogs\pylock.%my_flavor%-%WINPYARCH%bit-%WINPYVERLOCK%.toml + +python.exe -m pip freeze>%req% +findstr /v "winpython" %req% > %wanted_req% + + +rem pip lock from pypi, from the frozen req +python.exe -m pip lock --no-deps -c C:\WinP\constraints.txt -r "%wanted_req%" -o %pip_lock_web% + +rem pip lock from local WheelHouse, from the frozen req +python.exe -m pip lock --no-deps --no-index --trusted-host=None --find-links=C:\WinP\packages.srcreq -c C:\WinP\constraints.txt -r "%wanted_req%" -o %pip_lock_local% + +rem generating also classic requirement with hash-256, from obtained pylock.toml +python.exe -c "from winpython import wheelhouse as wh;wh.pylock_to_req(r'%pip_lock_web%', r'%req_lock_web%')" +python.exe -c "from winpython import wheelhouse as wh;wh.pylock_to_req(r'%pip_lock_local%', r'%req_lock_local%')" + +rem compare the two (result from pypi and local Wheelhouse must be equal) +fc "%req_lock_web%" "%req_lock_local%" + +copy/Y %pip_lock_web% %my_archive_lockfile% +copy/Y %pip_lock_web% %my_changelog_lockfile% + + REM Archive success echo -------------------------------------- >>%my_archive_log% echo "(%date% %time%) Archive success">>%my_archive_log% echo -------------------------------------- >>%my_archive_log% +set path=%my_original_path% +call %my_WINPYDIRBASE%\scripts\env.bat + %target_python_exe% -m pip freeze > %my_archive_log%.packages_versions.txt REM Generate changelog and binaries @@ -112,8 +204,10 @@ echo "(%date% %time%) Generate changelog and binaries">>%my_archive_log% set path=%my_original_path% cd /D %~dp0 call %my_buildenv%\scripts\env.bat + python.exe -c "from make import *;make_all(%my_release%, '%my_release_level%', pyver='%my_pyver%', basedir=r'%my_basedir%', verbose=True, architecture=%my_arch%, flavor='%my_flavor%', install_options=r'%my_install_options%', find_links=r'%my_find_links%', source_dirs=r'%my_source_dirs%', create_installer='%my_create_installer%', rebuild=False, python_target_release='%my_python_target_release%')" >> %my_archive_log% + echo -------------------------------------- >>%my_archive_log% echo "(%date% %time%) END OF CREATION">>%my_archive_log% echo -------------------------------------- >>%my_archive_log% diff --git a/generate_winpython_distros313_wheel.bat b/generate_winpython_distros313_wheel.bat new file mode 100644 index 00000000..02edeb99 --- /dev/null +++ b/generate_winpython_distros313_wheel.bat @@ -0,0 +1,40 @@ +rem this replace running manually from spyder the make.py +rem to launch from a winpython module 'make' directory + +set my_original_path=%path% + +set my_root_dir_for_builds=C:\Winp +set my_python_target=313 +set my_pyver=3.13 +set my_flavor=whl +set my_arch=64 + +rem settings delegated to generate_a_winpython_distro.bat +set my_release= +set my_release_level= + +rem list of installers to create separated per dot: False=none, .zip=zip, .7z=.7z, 7zip=auto-extractible 7z +set my_create_installer=7zip-mx5.7z-mx7.zip +set my_create_installer=.7z-mx7 + +set my_preclear_build_directory=Yes + +set tmp_reqdir=%my_root_dir_for_builds%\bd%my_python_target% + +set my_requirements=C:\Winp\bd313\dot_requirements.txt +set my_source_dirs=C:\Winp\bd313\packages.win-amd64 + +set my_find_links=C:\Winp\packages.srcreq +set my_toolsdirs=C:\Winp\bdTools\Tools.dot +set my_docsdirs=C:\WinP\bdDocs\docs.dot + +set my_install_options=--no-index --pre --trusted-host=None + +rem set addlockfile=dot + +set wheelhousereq=C:\Winp\bd313\requirements64_whl.txt + + +call %~dp0\generate_a_winpython_distro.bat + +pause diff --git a/make.py b/make.py index c1a6a93a..ec75c078 100644 --- a/make.py +++ b/make.py @@ -12,9 +12,7 @@ import subprocess import sys from pathlib import Path -from winpython import wppm, utils -# Local import -import diff +from winpython import wppm, utils, diff # Define constant paths for clarity CHANGELOGS_DIRECTORY = Path(__file__).parent / "changelogs" @@ -102,26 +100,13 @@ def _get_python_zip_file(self) -> Path: @property def package_index_markdown(self) -> str: """Generates a Markdown formatted package index page.""" - return f"""## WinPython {self.winpyver2 + self.flavor} - -The following packages are included in WinPython-{self.architecture_bits}bit v{self.winpyver2 + self.flavor} {self.release_level}. - -
- -### Tools - -Name | Version | Description ------|---------|------------ -{utils.get_installed_tools_markdown(utils.get_python_executable(self.python_executable_directory))} - -### Python packages - -Name | Version | Description ------|---------|------------ -{self.distribution.get_installed_packages_markdown()} - -
-""" + return self.distribution.generate_package_index_markdown( + self.python_executable_directory, + self.winpyver2, + self.flavor, + self.architecture_bits, + self.release_level + ) @property def winpython_version_name(self) -> str: @@ -146,17 +131,18 @@ def architecture_bits(self) -> int: """Returns the architecture (32 or 64 bits) of the distribution.""" return self.distribution.architecture if self.distribution else 64 - def create_installer_7zip(self, installer_type: str = ".exe"): - """Creates a WinPython installer using 7-Zip: ".exe", ".7z", ".zip")""" + def create_installer_7zip(self, installer_type: str = "exe", compression= "mx5"): + """Creates a WinPython installer using 7-Zip: "exe", "7z", "zip")""" self._print_action(f"Creating WinPython installer ({installer_type})") - if installer_type not in [".exe", ".7z", ".zip"]: - raise RuntimeError("installer_type {installer_type} is undefined") + if installer_type not in ["exe", "7z", "zip"]: + return DISTDIR = self.winpython_directory filename_stem = f"Winpython{self.architecture_bits}-{self.python_full_version}.{self.build_number}{self.flavor}{self.release_level}" - fullfilename = DISTDIR.parent / (filename_stem + installer_type) - sfx_option = "-sfx7z.sfx" if installer_type == ".exe" else "" - zip_option = "-tzip" if installer_type == ".zip" else "" - command = f'"{find_7zip_executable()}" {zip_option} -mx5 a "{fullfilename}" "{DISTDIR}" {sfx_option}' + fullfilename = DISTDIR.parent / (filename_stem + "." + installer_type) + sfx_option = "-sfx7z.sfx" if installer_type == "exe" else "" + zip_option = "-tzip" if installer_type == "zip" else "" + compress_level = "mx5" if compression == "" else compression + command = f'"{find_7zip_executable()}" {zip_option} -{compress_level} a "{fullfilename}" "{DISTDIR}" {sfx_option}' print(f'Executing 7-Zip script: "{command}"') try: subprocess.run(command, shell=True, check=True, stderr=sys.stderr, stdout=sys.stderr) @@ -208,6 +194,7 @@ def _create_initial_batch_scripts(self): # Replacements for batch scripts (PyPy compatibility) executable_name = self.distribution.short_exe if self.distribution else "python.exe" # default to python.exe if distribution is not yet set init_variables = [('WINPYthon_exe', executable_name), ('WINPYthon_subdirectory_name', self.python_directory_name), ('WINPYVER', self.winpython_version_name)] + init_variables += [('WINPYVER2', f"{self.python_full_version}.{self.build_number}"), ('WINPYFLAVOR', self.flavor), ('WINPYARCH', self.architecture_bits)] with open(self.winpython_directory / "scripts" / "env.ini", "w") as f: f.writelines([f'{a}={b}\n' for a, b in init_variables]) @@ -221,10 +208,7 @@ def build(self, rebuild: bool = True, requirements_files_list=None, winpy_dirnam if rebuild: self._print_action(f"Creating WinPython {self.winpython_directory} base directory") if self.winpython_directory.is_dir(): - try: - shutil.rmtree(self.winpython_directory, onexc=utils.onerror) - except TypeError: # before 3.12 - shutil.rmtree(self.winpython_directory, onerror=utils.onerror) + shutil.rmtree(self.winpython_directory) os.makedirs(self.winpython_directory, exist_ok=True) # preventive re-Creation of settings directory (self.winpython_directory / "settings" / "AppData" / "Roaming").mkdir(parents=True, exist_ok=True) @@ -260,14 +244,14 @@ def build(self, rebuild: bool = True, requirements_files_list=None, winpy_dirnam self._print_action("Writing changelog") shutil.copyfile(output_markdown_filename, str(Path(CHANGELOGS_DIRECTORY) / Path(output_markdown_filename).name)) - diff.write_changelog(self.winpyver2, None, self.base_directory, self.flavor, self.distribution.architecture) + 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-*"): if file.suffix in (".exe", ".whl", ".gz"): file.unlink() - utils.buildflit_wininst(source_directory, copy_to=target_directory, verbose=verbose) + utils.buildflit_wininst(source_directory, copy_to=target_directory, verbose=True) def make_all(build_number: int, release_level: str, pyver: str, architecture: int, basedir: Path, verbose: bool = False, rebuild: bool = True, create_installer: str = "True", install_options=["--no-index"], @@ -333,9 +317,9 @@ def make_all(build_number: int, release_level: str, pyver: str, architecture: in builder.build(rebuild=rebuild, requirements_files_list=requirements_files_list, winpy_dirname=winpython_dirname) - for installer_type in [".zip", ".7z", ".exe"]: - if installer_type in create_installer.lower().replace("7zip",".exe"): - builder.create_installer_7zip(installer_type) + for commmand in create_installer.lower().replace("7zip",".exe").split('.'): + installer_type, compression = (commmand + "-").split("-")[:2] + builder.create_installer_7zip(installer_type, compression) if __name__ == "__main__": # DO create only one Winpython distribution at a time diff --git a/portable/launchers_final/IDLE (Python GUI).exe b/portable/launchers_final/IDLE (Python GUI).exe index c1e0c9dc..65523199 100644 Binary files a/portable/launchers_final/IDLE (Python GUI).exe and b/portable/launchers_final/IDLE (Python GUI).exe differ diff --git a/portable/launchers_final/Spyder.exe b/portable/launchers_final/Spyder.exe index 43874aa7..93b1a050 100644 Binary files a/portable/launchers_final/Spyder.exe and b/portable/launchers_final/Spyder.exe differ diff --git a/portable/launchers_final/WinPython Control Panel.exe b/portable/launchers_final/WinPython Control Panel.exe index 5795bf9c..72a57a67 100644 Binary files a/portable/launchers_final/WinPython Control Panel.exe and b/portable/launchers_final/WinPython Control Panel.exe differ diff --git a/portable/launchers_final_proposed/IDLE (Python GUI).exe b/portable/launchers_final_proposed/IDLE (Python GUI).exe index 01eed2ce..65523199 100644 Binary files a/portable/launchers_final_proposed/IDLE (Python GUI).exe and b/portable/launchers_final_proposed/IDLE (Python GUI).exe differ diff --git a/portable/launchers_final_proposed/IDLE (Python GUI)_2025-05-09_not_ok.exe b/portable/launchers_final_proposed/IDLE (Python GUI)_2025-05-09_not_ok.exe new file mode 100644 index 00000000..01eed2ce Binary files /dev/null and b/portable/launchers_final_proposed/IDLE (Python GUI)_2025-05-09_not_ok.exe differ diff --git a/portable/launchers_final_proposed/IDLE (Python GUI)_20250401.exe b/portable/launchers_final_proposed/IDLE (Python GUI)_20250401.exe new file mode 100644 index 00000000..c1e0c9dc Binary files /dev/null and b/portable/launchers_final_proposed/IDLE (Python GUI)_20250401.exe differ diff --git a/portable/launchers_final_proposed/Spyder.exe b/portable/launchers_final_proposed/Spyder.exe index f3729201..93b1a050 100644 Binary files a/portable/launchers_final_proposed/Spyder.exe and b/portable/launchers_final_proposed/Spyder.exe differ diff --git a/portable/launchers_final_proposed/Spyder_2025-05-08_no_drag_and_drop.exe b/portable/launchers_final_proposed/Spyder_2025-05-08_no_drag_and_drop.exe new file mode 100644 index 00000000..f3729201 Binary files /dev/null and b/portable/launchers_final_proposed/Spyder_2025-05-08_no_drag_and_drop.exe differ diff --git a/portable/launchers_final_proposed/WinPython Control Panel.exe b/portable/launchers_final_proposed/WinPython Control Panel.exe index 664ff576..72a57a67 100644 Binary files a/portable/launchers_final_proposed/WinPython Control Panel.exe and b/portable/launchers_final_proposed/WinPython Control Panel.exe differ diff --git a/portable/launchers_final_proposed/WinPython Control Panel_2025-05-09.exe b/portable/launchers_final_proposed/WinPython Control Panel_2025-05-09.exe new file mode 100644 index 00000000..664ff576 Binary files /dev/null and b/portable/launchers_final_proposed/WinPython Control Panel_2025-05-09.exe differ diff --git a/portable/scripts/WinPython_Terminal.bat b/portable/scripts/WinPython_Terminal.bat deleted file mode 100644 index 3e55a021..00000000 --- a/portable/scripts/WinPython_Terminal.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -Powershell.exe -Command "& {Start-Process PowerShell.exe -ArgumentList '-ExecutionPolicy RemoteSigned -noexit -File ""%~dp0WinPython_PS_Prompt.ps1""'}" -exit \ No newline at end of file diff --git a/portable/scripts/register_python_for_all.bat b/portable/scripts/register_python_for_all.bat deleted file mode 100644 index 9f45aa70..00000000 --- a/portable/scripts/register_python_for_all.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -call "%~dp0env.bat" -"%WINPYDIR%\python.exe" "%WINPYDIR%\Lib\site-packages\winpython\associate.py" --all diff --git a/portable/scripts/unregister_python_for_all.bat b/portable/scripts/unregister_python_for_all.bat deleted file mode 100644 index b1600226..00000000 --- a/portable/scripts/unregister_python_for_all.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -call "%~dp0env.bat" -"%WINPYDIR%\python.exe" "%WINPYDIR%\Lib\site-packages\winpython\associate.py" --unregister --all diff --git a/portable/scripts/winspyder.bat b/portable/scripts/winspyder.bat index deae01c5..1a843ca0 100644 --- a/portable/scripts/winspyder.bat +++ b/portable/scripts/winspyder.bat @@ -1,3 +1,5 @@ @echo off call "%~dp0env_for_icons.bat" %* -"%WINPYDIR%\scripts\spyder.exe" %* -w "%WINPYWORKDIR1%" \ No newline at end of file +rem "%WINPYDIR%\scripts\spyder.exe" %* -w "%WINPYWORKDIR1%" +"%WINPYDIR%\scripts\spyder.exe" %* + diff --git a/winpython/__init__.py b/winpython/__init__.py index b865bf4c..468eed57 100644 --- a/winpython/__init__.py +++ b/winpython/__init__.py @@ -28,6 +28,6 @@ OTHER DEALINGS IN THE SOFTWARE. """ -__version__ = '15.4.20250507' +__version__ = '16.4.20250603' __license__ = __doc__ __project_url__ = 'http://winpython.github.io/' diff --git a/winpython/associate.py b/winpython/associate.py index 5d5854ff..894d8bda 100644 --- a/winpython/associate.py +++ b/winpython/associate.py @@ -10,9 +10,71 @@ from pathlib import Path import importlib.util import winreg -from winpython import utils +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 --- @@ -53,7 +115,7 @@ def _has_pywin32(): def _remove_start_menu_folder(target, current=True, has_pywin32=False): "remove menu Folder for target WinPython if pywin32 exists" if has_pywin32: - utils.remove_winpython_start_menu_folder(current=current) + remove_winpython_start_menu_folder(current=current) else: print("Skipping start menu removal as pywin32 package is not installed.") @@ -68,7 +130,7 @@ def _get_shortcut_data(target, current=True, has_pywin32=False): bname, ext = Path(name).stem, Path(name).suffix if ext.lower() == ".exe": # Path for the shortcut file in the start menu folder - shortcut_name = str(Path(utils.create_winpython_start_menu_folder(current=current)) / bname) + '.lnk' + shortcut_name = str(Path(create_winpython_start_menu_folder(current=current)) / bname) + '.lnk' data.append( ( str(Path(wpdir) / name), # Target executable path @@ -133,8 +195,8 @@ def register_in_registery(target, current=True, reg_type=winreg.REG_SZ, verbose= lost_entries.append((rf"Software\Classes\Python.NoConFile\shell\Edit with IDLE", None, None)) if Path(spyder_exe).exists(): - dynamic_entries.append((rf"Software\Classes\Python.File\shell\Edit with Spyder\command", None, f'"{spyder_exe}" "%1"')) - dynamic_entries.append((rf"Software\Classes\Python.NoConFile\shell\Edit with Spyder\command", None, f'"{spyder_exe}" "%1"')) + 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)) @@ -180,9 +242,9 @@ def register(target, current=True, reg_type=winreg.REG_SZ, verbose=True): 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: - utils.create_shortcut(path, desc, fname, verbose=verbose) + create_shortcut(path, desc, fname, verbose=verbose) except Exception as e: - print(f"Error creating shortcut for {desc} at {fname}: {e}", file=sys.stderr) + 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.") diff --git a/winpython/diff.py b/winpython/diff.py new file mode 100644 index 00000000..a8e52e1d --- /dev/null +++ b/winpython/diff.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# +# WinPython diff.py script +# Copyright © 2013 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 +from pathlib import Path +import re +import shutil +from packaging import version +from . import utils + +CHANGELOGS_DIR = Path(__file__).parent.parent / "changelogs" +assert CHANGELOGS_DIR.is_dir() + +class Package: + PATTERNS = [ + r"\[([\w\-\:\/\.\_]+)\]\(([^)]+)\) \| ([^\|]*) \| ([^\|]*)", # SourceForge + r"\[([\w\-\:\/\.\_]+) ([^\]\ ]+)\] \| ([^\|]*) \| ([^\|]*)" # Google Code + ] + + def __init__(self, text=None): + self.name = self.url = self.version = self.description = None + if text: + self.from_text(text) + + def from_text(self, text): + for pattern in self.PATTERNS: + match = re.match(pattern, text) + if match: + self.name, self.url, self.version, self.description = match.groups() + return + raise ValueError(f"Unrecognized package line format: {text}") + + def to_wiki(self): + return f" * [{self.name}]({self.url}) {self.version} ({self.description})\n" + + def upgrade_wiki(self, other): + return f" * [{self.name}]({self.url}) {other.version} → {self.version} ({self.description})\n" + +class PackageIndex: + HEADERS = {"tools": "### Tools", "python": "### Python packages", "wheelhouse": "### WheelHouse packages"} + BLANKS = ["Name | Version | Description", "-----|---------|------------", "", "
", "
"] + + def __init__(self, version, searchdir=None, flavor="", architecture=64): + self.version = version + self.flavor = flavor + self.searchdir = searchdir + self.architecture = architecture + self.packages = {"tools": {}, "python": {}, "wheelhouse": {}} + self._load_index() + + def _load_index(self): + filename = self.searchdir / f"WinPython{self.flavor}-{self.architecture}bit-{self.version}.md" + if not filename.exists(): + raise FileNotFoundError(f"Changelog not found: {filename}") + + with open(filename, "r", encoding=utils.guess_encoding(filename)[0]) as f: + self._parse_index(f.read()) + + def _parse_index(self, text): + current = None + for line in text.splitlines(): + if line in self.HEADERS.values(): + current = [k for k, v in self.HEADERS.items() if v == line][0] + continue + if line.strip() in self.BLANKS: + continue + if current: + pkg = Package(line) + self.packages[current][pkg.name] = pkg + +def compare_packages(old, new): + """Return difference between package old and package new""" + + # wheel replace '-' per '_' in key + def normalize(d): return {k.replace("-", "_").lower(): v for k, v in d.items()} + old, new = normalize(old), normalize(new) + output = "" + + added = [new[k].to_wiki() for k in new if k not in old] + upgraded = [new[k].upgrade_wiki(old[k]) for k in new if k in old and new[k].version != old[k].version] + removed = [old[k].to_wiki() for k in old if k not in new] + + if added: + output += "New packages:\n\n" + "".join(added) + "\n\n" + if upgraded: + output += "Upgraded packages:\n\n" + "".join(upgraded) + "\n\n" + if removed: + output += "Removed packages:\n\n" + "".join(removed) + "\n\n" + return output + +def find_previous_version(target_version, searchdir=None, flavor="", architecture=64): + """Find version which is the closest to `version`""" + search_dir = Path(searchdir) if searchdir else CHANGELOGS_DIR + pattern = re.compile(rf"WinPython{flavor}-{architecture}bit-([0-9\.]+)\.(txt|md)") + versions = [pattern.match(f).group(1) for f in os.listdir(search_dir) if pattern.match(f)] + versions = [v for v in versions if version.parse(v) < version.parse(target_version)] + return max(versions, key=version.parse, default=target_version) + +def compare_package_indexes(version2, version1=None, searchdir=None, flavor="", flavor1=None, architecture=64): + version1 = version1 or find_previous_version(version2, searchdir, flavor, architecture) + flavor1 = flavor1 or flavor + + pi1 = PackageIndex(version1, searchdir, flavor1, architecture) + pi2 = PackageIndex(version2, searchdir, flavor, architecture) + + text = ( + f"## History of changes for WinPython-{architecture}bit {version2 + flavor}\r\n\r\n" + f"The following changes were made to WinPython-{architecture}bit distribution since version {version1 + flavor1}.\n\n\n" + "
\n\n" + ) + + for key in PackageIndex.HEADERS: + diff = compare_packages(pi1.packages[key], pi2.packages[key]) + if diff: + text += f"\n{PackageIndex.HEADERS[key]}\n\n{diff}" + + return text + "\n
\n\n* * *\n" + +def copy_changelogs(version, searchdir, flavor="", architecture=64, basedir=None): + basever = ".".join(version.split(".")[:2]) + pattern = re.compile(rf"WinPython{flavor}-{architecture}bit-{basever}[0-9\.]*\.(txt|md)") + dest = Path(basedir) + for fname in os.listdir(searchdir): + if pattern.match(fname): + shutil.copyfile(searchdir / fname, dest / fname) + +def write_changelog(version2, version1=None, searchdir=None, flavor="", architecture=64, basedir=None): + """Write changelog between version1 and version2 of WinPython""" + if basedir: + copy_changelogs(version2, searchdir, flavor, architecture, basedir) + print("comparing_package_indexes", version2, searchdir, flavor, architecture) + changelog = compare_package_indexes(version2, version1, searchdir, flavor, architecture=architecture) + output_file = searchdir / f"WinPython{flavor}-{architecture}bit-{version2}_History.md" + with open(output_file, "w", encoding="utf-8") as f: + f.write(changelog) + # Copy to winpython/changelogs back to basedir + if basedir: + shutil.copyfile(output_file, basedir / output_file.name) + +if __name__ == "__main__": + print(compare_package_indexes("3.7.4.0", "3.7.2.0", r"C:\WinP\bd37\budot", "Zero", architecture=32)) + write_changelog("3.7.4.0", "3.7.2.0", r"C:\WinP\bd37\budot", "Ps2", architecture=64) diff --git a/winpython/packagemetadata.py b/winpython/packagemetadata.py new file mode 100644 index 00000000..4e2a4989 --- /dev/null +++ b/winpython/packagemetadata.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +packagemetadata.py - get metadata from designated place +""" +import os +import re +import tarfile +import zipfile +import sys +from pathlib import Path +from collections import defaultdict +import shutil +import subprocess +from typing import Dict, List, Optional, Tuple +from . import utils + +# --- Abstract metadata accessor --- + +class PackageMetadata: + """A minimal abstraction for package metadata.""" + def __init__(self, name, version, requires, summary, description, metadata): + self.name = name + self.version = version + self.requires = requires # List[str] of dependencies + self.summary = summary + self.description = description + self.metadata = metadata + +def get_installed_metadata(path = None) -> List[PackageMetadata]: + # Use importlib.metadata or pkg_resources + import importlib.metadata + pkgs = [] + distro = importlib.metadata.distributions(path = path) if path else importlib.metadata.distributions() + for dist in distro: + name = dist.metadata['Name'] + version = dist.version + summary = dist.metadata.get("Summary", ""), + description = dist.metadata.get("Description", ""), + requires = dist.requires or [] + metadata = dist.metadata + pkgs.append(PackageMetadata(name, version, requires, summary, description, metadata)) + return pkgs + +def get_directory_metadata(directory: str) -> List[PackageMetadata]: + # For each .whl/.tar.gz file in directory, extract metadata + pkgs = [] + for fname in os.listdir(directory): + if fname.endswith('.whl'): + # Extract METADATA from wheel + meta = extract_metadata_from_wheel(os.path.join(directory, fname)) + pkgs.append(meta) + elif fname.endswith('.tar.gz'): + # Extract PKG-INFO from sdist + meta = extract_metadata_from_sdist(os.path.join(directory, fname)) + pkgs.append(meta) + return pkgs + +def extract_metadata_from_wheel(path: str) -> PackageMetadata: + import zipfile + with zipfile.ZipFile(path) as zf: + for name in zf.namelist(): + if name.endswith(r'.dist-info/METADATA') and name.split("/")[1] == "METADATA": + with zf.open(name) as f: + # Parse metadata (simple parsing for Name, Version, Requires-Dist) + return parse_metadata_file(f.read().decode()) + raise ValueError(f"No METADATA found in {path}") + +def extract_metadata_from_sdist(path: str) -> PackageMetadata: + import tarfile + with tarfile.open(path, "r:gz") as tf: + for member in tf.getmembers(): + if member.name.endswith('PKG-INFO'): + f = tf.extractfile(member) + return parse_metadata_file(f.read().decode()) + raise ValueError(f"No PKG-INFO found in {path}") + +def parse_metadata_file(txt: str) -> PackageMetadata: + name = version = summary = description = "" + requires = [] + description_lines = [] + in_description = False + for line in txt.splitlines(): + if line.startswith('Name: '): + name = line[6:].strip() + elif line.startswith('Version: '): + version = line[9:].strip() + elif line.startswith('Summary: '): + summary = description = line[9:].strip() + elif line.startswith('Requires-Dist: '): + requires.append(line[14:].strip()) + return PackageMetadata(name, version, requires, summary, description, {'Name': name, "Summary": summary, "Description": description}) + +def main(): + if len(sys.argv) > 1: + # Directory mode + directory = sys.argv[1] + pkgs = get_directory_metadata(directory) + else: + # Installed packages mode + pkgs = get_installed_metadata() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/winpython/piptree.py b/winpython/piptree.py index 23a53ff3..e46bd465 100644 --- a/winpython/piptree.py +++ b/winpython/piptree.py @@ -17,6 +17,8 @@ from pip._vendor.packaging.markers import Marker from importlib.metadata import Distribution, distributions from pathlib import Path +from . import utils +from . import packagemetadata as pm logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -25,19 +27,10 @@ class PipDataError(Exception): """Custom exception for PipData related errors.""" pass -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] - 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.rstrip() - return summary[:max_length].rstrip() - class PipData: """Manages package metadata and dependency relationships in a Python environment.""" - def __init__(self, target: Optional[str] = None): + def __init__(self, target: Optional[str] = None, wheelhouse = None): """ Initialize the PipData instance. @@ -47,7 +40,7 @@ def __init__(self, target: Optional[str] = None): self.raw: Dict[str, Dict] = {} self.environment = self._get_environment() try: - packages = self._get_packages(target or sys.executable) + packages = self._get_packages(target or sys.executable, wheelhouse) self._process_packages(packages) self._populate_reverse_dependencies() except Exception as e: @@ -75,12 +68,14 @@ def _get_environment(self) -> Dict[str, str]: "sys_platform": sys.platform, } - def _get_packages(self, search_path: str) -> List[Distribution]: + def _get_packages(self, search_path: str, wheelhouse) -> List[Distribution]: """Retrieve installed packages from the specified path.""" + if wheelhouse: + return pm.get_directory_metadata(wheelhouse) if sys.executable == search_path: - return Distribution.discover() + return pm.get_installed_metadata() #Distribution.discover() else: - return distributions(path=[str(Path(search_path).parent / 'lib' / 'site-packages')]) + return pm.get_installed_metadata(path=[str(Path(search_path).parent / 'lib' / 'site-packages')]) #distributions(path=[str(Path(search_path).parent / 'lib' / 'site-packages')]) def _process_packages(self, packages: List[Distribution]) -> None: """Process packages metadata and store them in the distro dictionary.""" @@ -287,5 +282,5 @@ def pip_list(self, full: bool = False, max_length: int = 144) -> List[Tuple[str, """ pkgs = sorted(self.distro.items()) if full: - return [(p, d["version"], sum_up(d["summary"], max_length)) for p, d in pkgs] + return [(p, d["version"], utils.sum_up(d["summary"], max_length)) for p, d in pkgs] return [(p, d["version"]) for p, d in pkgs] diff --git a/winpython/utils.py b/winpython/utils.py index 40961ec1..2d549f20 100644 --- a/winpython/utils.py +++ b/winpython/utils.py @@ -11,15 +11,11 @@ import stat import shutil import locale -import tempfile import subprocess -import configparser as cp from pathlib import Path import re import tarfile import zipfile -import atexit -import winreg # 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)' @@ -43,25 +39,24 @@ def get_site_packages_path(path=None): 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_markdown(path=None)-> str: +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(f"[Python](http://www.python.org/) | {version} | Python programming language with standard library") + 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(f"[Nodejs](https://nodejs.org) | {version} | a JavaScript runtime built on Chrome's V8 JavaScript engine") + 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(f"[Pandoc](https://pandoc.org) | {version} | an universal document converter") + 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(f"[VSCode](https://code.visualstudio.com) | {version} | a source-code editor developed by Microsoft") - return "\n".join(tool_lines) - + 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`.""" @@ -71,93 +66,14 @@ def onerror(function, path, excinfo): else: raise -def getFileProperties(fname): - """Read all properties of the given file return them as a dictionary.""" - import win32api - prop_names = ('ProductName', 'ProductVersion', 'FileDescription', 'FileVersion') - props = {'FixedFileInfo': None, 'StringFileInfo': None, 'FileVersion': None} - - try: - fixed_info = win32api.GetFileVersionInfo(fname, '\\') - props['FixedFileInfo'] = fixed_info - props['FileVersion'] = "{}.{}.{}.{}".format( - fixed_info['FileVersionMS'] // 65536, - fixed_info['FileVersionMS'] % 65536, - fixed_info['FileVersionLS'] // 65536, - fixed_info['FileVersionLS'] % 65536 - ) - lang, codepage = win32api.GetFileVersionInfo(fname, '\\VarFileInfo\\Translation')[0] - props['StringFileInfo'] = { - prop_name: win32api.GetFileVersionInfo(fname, f'\\StringFileInfo\\{lang:04X}{codepage:04X}\\{prop_name}') - for prop_name in prop_names - } - except: - pass - - return props - -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 !") +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""" diff --git a/winpython/wheelhouse.py b/winpython/wheelhouse.py new file mode 100644 index 00000000..f500dc31 --- /dev/null +++ b/winpython/wheelhouse.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +WheelHouse.py - manage WinPython local WheelHouse. +""" +import os +import re +import tarfile +import zipfile +import sys +from pathlib import Path +from collections import defaultdict +import shutil +import subprocess +from typing import Dict, List, Optional, Tuple +from . import packagemetadata as pm +from . import utils + +from packaging.utils import canonicalize_name, parse_wheel_filename, parse_sdist_filename + +# Use tomllib if available (Python 3.11+), otherwise fall back to tomli +try: + import tomllib # Python 3.11+ +except ImportError: + try: + import tomli as tomllib # For older Python versions + except ImportError: + print("Please install tomli for Python < 3.11: pip install tomli") + sys.exit(1) + +def parse_pylock_toml(path: Path) -> Dict[str, Dict[str, str | List[str]]]: + """Parse a pylock.toml file and extract package information.""" + with open(path, "rb") as f: + data = tomllib.load(f) + + # This dictionary maps package names to (version, [hashes]) + package_hashes = defaultdict(lambda: {"version": "", "hashes": []}) + + for entry in data.get("packages", []): + name = entry["name"] + version = entry["version"] + all_hashes = [] + + # Handle wheels + for wheel in entry.get("wheels", []): + sha256 = wheel.get("hashes", {}).get("sha256") + if sha256: + all_hashes.append(sha256) + + # Handle sdist (if present) + sdist = entry.get("sdist") + if sdist and "hashes" in sdist: + sha256 = sdist["hashes"].get("sha256") + if sha256: + all_hashes.append(sha256) + + package_hashes[name]["version"] = version + package_hashes[name]["hashes"].extend(all_hashes) + + return package_hashes + +def write_requirements_txt(package_hashes: Dict[str, Dict[str, str | List[str]]], output_path: Path) -> None: + """Write package requirements to a requirements.txt file.""" + with open(output_path, "w") as f: + for name, data in sorted(package_hashes.items()): + version = data["version"] + hashes = data["hashes"] + + if hashes: + f.write(f"{name}=={version} \\\n") + for i, h in enumerate(hashes): + end = " \\\n" if i < len(hashes) - 1 else "\n" + f.write(f" --hash=sha256:{h}{end}") + else: + f.write(f"{name}=={version}\n") + + print(f"✅ requirements.txt written to {output_path}") + +def pylock_to_req(path: Path, output_path: Optional[Path] = None) -> None: + """Convert a pylock.toml file to requirements.txt.""" + pkgs = parse_pylock_toml(path) + if not output_path: + output_path = path.parent / (path.stem.replace('pylock', 'requirement_with_hash') + '.txt') + write_requirements_txt(pkgs, output_path) + +def run_pip_command(command: List[str], check: bool = True, capture_output=True) -> Tuple[bool, Optional[str]]: + """Run a pip command and return the result.""" + print('\n', ' '.join(command),'\n') + try: + result = subprocess.run( + command, + capture_output=capture_output, + text=True, + check=check + ) + return (result.returncode == 0), (result.stderr or result.stdout) + except subprocess.CalledProcessError as e: + return False, e.stderr + except FileNotFoundError: + return False, "pip or Python not found." + except Exception as e: + return False, f"Unexpected error: {e}" + +def get_wheels(requirements: Path, wheeldrain: Path, wheelorigin: Optional[Path] = None + , only_check: bool = True,post_install: bool = False) -> bool: + """Download or check Python wheels based on requirements.""" + added = [] + if wheelorigin: + added = ['--no-index', '--trusted-host=None', f'--find-links={wheelorigin}'] + pre_checks = [sys.executable, "-m", "pip", "install", "--dry-run", "--no-deps", "--require-hashes", "-r", str(requirements)] + added + instruction = [sys.executable, "-m", "pip", "download", "--no-deps", "--require-hashes", "-r", str(requirements), "--dest", str(wheeldrain)] + added + if wheeldrain: + added = ['--no-index', '--trusted-host=None', f'--find-links={wheeldrain}'] + post_install_cmd = [sys.executable, "-m", "pip", "install", "--no-deps", "--require-hashes", "-r", str(requirements)] + added + + # Run pip dry-run, only if a move of wheels + if wheelorigin and wheelorigin != wheeldrain: + success, output = run_pip_command(pre_checks, check=False) + if not success: + print("❌ Dry-run failed. Here's the output:\n") + print(output or "") + return False + + print("✅ Requirements can be installed successfully (dry-run passed).\n") + + # All ok + if only_check and not post_install: + return True + + # Want to install + if not only_check and post_install: + success, output = run_pip_command(post_install_cmd, check=False, capture_output=False) + if not success: + print("❌ Installation failed. Here's the output:\n") + print(output or "") + return False + return True + + # Otherwise download also, but not install direct + success, output = run_pip_command(instruction) + if not success: + print("❌ Download failed. Here's the output:\n") + print(output or "") + return False + + return True + +def get_pylock_wheels(wheelhouse: Path, lockfile: Path, wheelorigin: Optional[Path] = None, wheeldrain: Optional[Path] = None) -> None: + """Get wheels asked pylock file.""" + filename = Path(lockfile).name + wheelhouse.mkdir(parents=True, exist_ok=True) + trusted_wheelhouse = wheelhouse / "included.wheels" + trusted_wheelhouse.mkdir(parents=True, exist_ok=True) + + filename_lock = wheelhouse / filename + filename_req = wheelhouse / (Path(lockfile).stem.replace('pylock', 'requirement') + '.txt') + + pylock_to_req(Path(lockfile), filename_req) + + if not str(Path(lockfile)) == str(filename_lock): + shutil.copy2(lockfile, filename_lock) + + # We create a destination for wheels that is specific, so we can check all is there + destination_wheelhouse = Path(wheeldrain) if wheeldrain else wheelhouse / Path(lockfile).name.replace('.toml', '.wheels') + destination_wheelhouse.mkdir(parents=True, exist_ok=True) + # there can be an override + + + in_trusted = False + + if wheelorigin is None: + # Try from trusted WheelHouse + print(f"\n\n*** Checking if we can install from our Local WheelHouse: ***\n {trusted_wheelhouse}\n\n") + in_trusted = get_wheels(filename_req, destination_wheelhouse, wheelorigin=trusted_wheelhouse, only_check=True) + if in_trusted: + print(f"\n\n*** We can install from Local WheelHouse: ***\n {trusted_wheelhouse}\n\n") + in_installed = get_wheels(filename_req, trusted_wheelhouse, wheelorigin=trusted_wheelhouse, only_check=False, post_install=True) + + if not in_trusted: + post_install = True if wheelorigin and Path(wheelorigin).is_dir and Path(wheelorigin).samefile(destination_wheelhouse) else False + if post_install: + print(f"\n\n*** Installing from Local WheelHouse: ***\n {destination_wheelhouse}\n\n") + else: + print(f"\n\n*** Re-Checking if we can install from: {'pypi.org' if not wheelorigin or wheelorigin == '' else wheelorigin}\n\n") + + in_pylock = get_wheels(filename_req, destination_wheelhouse, wheelorigin=wheelorigin, only_check=False, post_install=post_install) + if in_pylock: + if not post_install: + print(f"\n\n*** You can now install from this dedicated WheelHouse: ***\n {destination_wheelhouse}") + print(f"\n via:\n wppm {filename_lock} -wh {destination_wheelhouse}\n") + else: + print(f"\n\n*** We can't install {filename} ! ***\n\n") + +def list_packages_with_metadata(directory: str) -> List[Tuple[str, str, str]]: + "get metadata from a Wheelhouse directory" + packages = pm.get_directory_metadata(directory) + results = [ (p.name, p.version, p.summary) for p in packages] + return results + +def main() -> None: + """Main entry point for the script.""" + if len(sys.argv) != 2: + print("Usage: python pylock_to_requirements.py pylock.toml") + sys.exit(1) + + path = Path(sys.argv[1]) + if not path.exists(): + print(f"❌ File not found: {path}") + sys.exit(1) + + pkgs = parse_pylock_toml(path) + dest = path.parent / (path.stem.replace('pylock', 'requirement_with_hash') + '.txt') + write_requirements_txt(pkgs, dest) + +if __name__ == "__main__": + main() diff --git a/winpython/wppm.py b/winpython/wppm.py index 659d14a4..01ed2c83 100644 --- a/winpython/wppm.py +++ b/winpython/wppm.py @@ -14,8 +14,9 @@ import json from pathlib import Path from argparse import ArgumentParser, RawTextHelpFormatter -from winpython import utils, piptree, associate - +from . import utils, piptree, associate +from . import wheelhouse as wh +from operator import itemgetter # Workaround for installing PyVISA on Windows from source: os.environ["HOME"] = os.environ["USERPROFILE"] @@ -23,8 +24,8 @@ class Package: """Standardize a Package from filename or pip list.""" def __init__(self, fname: str, suggested_summary: str = None): self.fname = fname - self.description = piptree.sum_up(suggested_summary) if suggested_summary else "" - self.name, self.version = None, None + 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 @@ -47,6 +48,7 @@ def __init__(self, target: str = None, verbose: bool = False): 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""" @@ -67,13 +69,45 @@ def get_installed_packages(self, update: bool = False) -> list[Package]: 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 get_installed_packages_markdown(self) -> str: - """Generates Markdown for installed packages section in package index.""" - package_lines = [ - f"[{pkg.name}]({pkg.url}) | {pkg.version} | {pkg.description}" - for pkg in sorted(self.get_installed_packages(), key=lambda p: p.name.lower()) - ] - return "\n".join(package_lines) + 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) -> str: + """Generates a Markdown formatted package index page.""" + my_ver , my_arch = utils.get_python_infos(python_executable_directory or self.target) + # suppose we suite ourself (method will vary over time) + 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 = [] + wheeldir = self.wheelhouse / 'included.wheels' + if wheeldir.is_dir(): + wheelhouse_list = [(name, f"https://pypi.org/project/{name}", version, summary) + for name, version, summary in wh.list_packages_with_metadata(str(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.""" @@ -91,7 +125,8 @@ def patch_all_shebang(self, to_movable: bool = True, max_exe_size: int = 999999, def install(self, package: Package, install_options: list[str] = None): """Install package in distribution.""" - if package.fname.endswith((".whl", ".tar.gz", ".zip")): # Check extension with tuple + 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 @@ -231,7 +266,7 @@ def main(test=False): description="WinPython Package Manager: handle a WinPython Distribution and its packages", formatter_class=RawTextHelpFormatter, ) - parser.add_argument("fname", metavar="package", nargs="?", default="", type=str, help="optional package name or package wheel") + parser.add_argument("fname", metavar="package or lockfile", nargs="?", default="", type=str, help="optional package name or package wheel") 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( "--register_forall", action="store_true", help="Register distribution for all users") @@ -239,13 +274,16 @@ def main(test=False): # parser.add_argument( "--unregister_forall", action="store_true", help="un-Register distribution for all users") 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("-ls", "--list", action="store_true", help="list installed packages matching the given [optional] package expression: wppm -ls, wppm -ls pand") - parser.add_argument("-lsa", dest="all", action="store_true",help=f"list details of package names matching given regular expression: wppm -lsa pandas -l1") - parser.add_argument("-p",dest="pipdown",action="store_true",help="show Package dependencies of the given package[option]: wppm -p pandas[test]") - parser.add_argument("-r", dest="pipup", action="store_true", help=f"show Reverse dependancies of the given package[option]: wppm -r pytest[test]") - parser.add_argument("-l", "--levels", type=int, default=2, help="show 'LEVELS' levels of dependencies (with -p, -r), default is 2: wppm -p pandas -l1") - parser.add_argument("-t", "--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 (use pip for more features)") + 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 if 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 wppmdependancies of the given package[option]: wppm -r pytest[test]") + parser.add_argument("-l", dest="levels", type=int, default=2, help="show 'LEVELS' levels of dependencies (with -p, -r), default is 2: 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)") @@ -253,22 +291,26 @@ def main(test=False): 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) + pip = piptree.PipData(targetpython, args.wheelsource) pack, extra, *other = (args.fname + "[").replace("]", "[").split("[") print(pip.down(pack, extra, args.levels, verbose=args.verbose)) sys.exit() elif args.pipup: - pip = piptree.PipData(targetpython) + pip = piptree.PipData(targetpython, args.wheelsource) pack, extra, *other = (args.fname + "[").replace("]", "[").split("[") print(pip.up(pack, extra, args.levels, verbose=args.verbose)) sys.exit() elif args.list: - pip = piptree.PipData(targetpython) + pip = piptree.PipData(targetpython, args.wheelsource) todo = [l for l in pip.pip_list(full=True) if bool(re.search(args.fname, l[0]))] titles = [['Package', 'Version', 'Summary'], ['_' * max(x, 6) for x in utils.columns_width(todo)]] listed = utils.formatted_list(titles + todo, max_width=70) @@ -276,7 +318,7 @@ def main(test=False): print(*p) sys.exit() elif args.all: - pip = piptree.PipData(targetpython) + pip = piptree.PipData(targetpython, args.wheelsource) todo = [l for l in pip.pip_list(full=True) if bool(re.search(args.fname, l[0]))] for l in todo: # print(pip.distro[l[0]]) @@ -323,22 +365,29 @@ def main(test=False): if args.movable: p = subprocess.Popen(["start", "cmd", "/k",dist.python_exe, "-c" , cmd_mov], shell = True, cwd=dist.target) sys.exit() - if not args.install and not args.uninstall: - args.install = True - if not Path(args.fname).is_file() and args.install: - if args.fname == "": + if args.markdown: + print(dist.generate_package_index_markdown()) + sys.exit() + if not args.install and not args.uninstall and args.fname.endswith(".toml"): + args.install = True # for Drag & Drop of .toml (and not wheel) + if args.fname == "" or (not args.install and not args.uninstall): parser.print_help() sys.exit() - else: - raise FileNotFoundError(f"File not found: {args.fname}") + else: try: + 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) + dist.install(package, install_options=install_from_wheelhouse) except NotImplementedError: raise RuntimeError("Package is not (yet) supported by WPPM") else: