From 1be9e944b4a84da6f56abb1b6242ff28647324d9 Mon Sep 17 00:00:00 2001 From: stonebig Date: Sun, 25 May 2025 00:05:07 +0200 Subject: [PATCH] WheelHouse integrated management --- winpython/__init__.py | 2 +- winpython/wheelhouse.py | 140 ++++++++++++++++++++++++++++++++++++---- winpython/wppm.py | 12 +++- 3 files changed, 138 insertions(+), 16 deletions(-) diff --git a/winpython/__init__.py b/winpython/__init__.py index a9a855a8..c8ebca8a 100644 --- a/winpython/__init__.py +++ b/winpython/__init__.py @@ -28,6 +28,6 @@ OTHER DEALINGS IN THE SOFTWARE. """ -__version__ = '16.0.20250513' +__version__ = '16.1.20250524' __license__ = __doc__ __project_url__ = 'http://winpython.github.io/' diff --git a/winpython/wheelhouse.py b/winpython/wheelhouse.py index b29f560e..e93e7bc4 100644 --- a/winpython/wheelhouse.py +++ b/winpython/wheelhouse.py @@ -1,8 +1,14 @@ -# -# WheelHouse.py +#!/usr/bin/env python3 +""" +WheelHouse.py - manage WinPython local WheelHouse. +""" + import sys from pathlib import Path from collections import defaultdict +import shutil +import subprocess +from typing import Dict, List, Optional, Tuple # Use tomllib if available (Python 3.11+), otherwise fall back to tomli try: @@ -14,10 +20,9 @@ print("Please install tomli for Python < 3.11: pip install tomli") sys.exit(1) - - -def parse_pylock_toml(path): - with open(Path(path), "rb") as f: +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]) @@ -46,9 +51,9 @@ def parse_pylock_toml(path): return package_hashes - -def write_requirements_txt(package_hashes, output_path="requirements.txt"): - with open(Path(output_path), "w") as f: +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"] @@ -63,13 +68,119 @@ def write_requirements_txt(package_hashes, output_path="requirements.txt"): print(f"✅ requirements.txt written to {output_path}") -def pylock_to_req(path, output_path=None): +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') + output_path = path.parent / (path.stem.replace('pylock', 'requirement_with_hash') + '.txt') write_requirements_txt(pkgs, output_path) -if __name__ == "__main__": +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, wheeldir: Path, from_local: Optional[Path] = None + , only_check: bool = True,post_install: bool = False) -> bool: + """Download or check Python wheels based on requirements.""" + added = [] + if from_local: + added += ['--no-index', '--trusted-host=None', f'--find-links={from_local}'] + 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(wheeldir)] + added + 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 directory + if from_local and from_local != wheeldir: + 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, from_local: Optional[Path] = None) -> None: + """Get wheels for a 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_with_hash') + '.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 = wheelhouse / Path(lockfile).name.replace('.toml', '.wheels') + in_trusted = False + + if from_local 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, from_local=trusted_wheelhouse, only_check=True) + if in_trusted: + print(f"\n\n*** We can install from Local WheelHouse: ***\n {trusted_wheelhouse}\n\n") + user_input = input("Do you want to continue and install from {trusted_wheelhouse} ? (yes/no):") + if user_input.lower() == "yes": + in_installed = get_wheels(filename_req, trusted_wheelhouse, from_local=trusted_wheelhouse, only_check=True, post_install=True) + + if not in_trusted: + post_install = True if from_local and Path(from_local).is_dir and Path(from_local).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 from_local or from_local == '' else from_local}\n\n") + + in_pylock = get_wheels(filename_req, destination_wheelhouse, from_local=from_local, 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 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) @@ -80,5 +191,8 @@ def pylock_to_req(path, output_path=None): sys.exit(1) pkgs = parse_pylock_toml(path) - dest = path.parent / (path.stem.replace('pylock','requirement_with_hash')+ '.txt') + 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..258795e3 100644 --- a/winpython/wppm.py +++ b/winpython/wppm.py @@ -231,7 +231,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 +239,14 @@ 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("-wh", "--wheelhouse", default=None, type=str, help="wheelhouse location to search for wheels: wppm pylock.toml -wh directory_of_wheels") 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("-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)") @@ -331,7 +332,14 @@ def main(test=False): sys.exit() else: raise FileNotFoundError(f"File not found: {args.fname}") + else: try: + filename = Path(args.fname).name + if filename.split('.')[0] == "pylock" and filename.split('.')[-1] == 'toml': + print(' a lock file !', args.fname, dist.target) + from winpython import wheelhouse as wh + wh.get_pylock_wheels(Path(dist.target).parent/ "WheelHouse", Path(args.fname), args.wheelhouse) + sys.exit() if args.uninstall: package = dist.find_package(args.fname) dist.uninstall(package)