[searchdir] [flavor] [architecture] [basedir]
+ if len(args) < 4:
+ print_usage()
+ sys.exit(1)
+ version2 = args[2]
+ version1 = args[3]
+ searchdir = args[4] if len(args) > 4 else CHANGELOGS_DIR
+ flavor = args[5] if len(args) > 5 else ""
+ architecture = int(args[6]) if len(args) > 6 else 64
+ basedir = args[7] if len(args) > 7 else None
+ write_changelog(version2, version1, searchdir, flavor, architecture, basedir)
+ print(f"Changelog written for {version2} vs {version1}.")
+ elif len(args) >= 3:
+ version2 = args[1]
+ version1 = args[2] if len(args) > 2 and not args[2].endswith('.md') else None
+ searchdir = args[3] if len(args) > 3 else CHANGELOGS_DIR
+ flavor = args[4] if len(args) > 4 else ""
+ architecture = int(args[5]) if len(args) > 5 else 64
+ print(compare_package_indexes(version2, version1, searchdir, flavor, architecture=architecture))
+ else:
+ print_usage()
\ No newline at end of file
diff --git a/wppm/hash.py b/wppm/hash.py
new file mode 100644
index 00000000..e0bdd46a
--- /dev/null
+++ b/wppm/hash.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+"""
+hash.py: compute hash of given files into a markdown output
+"""
+# 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)
+
+from pathlib import Path
+import sys
+import hashlib
+
+def compute_hash(file_path, hash_function, digest_size=None):
+ """Compute the hash of a file using the specified hash function."""
+ try:
+ with open(file_path, 'rb') as file:
+ if digest_size:
+ return hash_function(file.read(), digest_size=digest_size).hexdigest()
+ return hash_function(file.read()).hexdigest()
+ except IOError as e:
+ print(f"Error reading file {file_path}: {e}")
+ return None
+
+def print_hashes(files):
+ """Print the hashes of the given files."""
+ header = f"{'MD5':<32} | {'SHA-1':<40} | {'SHA-256':<64} | {'Binary':<33} | {'Size':<20} | {'blake2b-256':<64}"
+ line = "|".join(["-" * len(part) for part in header.split("|")])
+
+ print(header)
+ print(line)
+
+ for file in sorted(files):
+ md5 = compute_hash(file, hashlib.md5)
+ sha1 = compute_hash(file, hashlib.sha1)
+ sha256 = compute_hash(file, hashlib.sha256)
+ name = Path(file).name.ljust(33)
+ size = f"{Path(file).stat().st_size:,} Bytes".replace(",", " ").rjust(20)
+ blake2b = compute_hash(file, hashlib.blake2b, digest_size=32)
+ print(f"{md5} | {sha1} | {sha256} | {name} | {size} | {blake2b}")
+
+if __name__ == '__main__':
+ if len(sys.argv) < 2:
+ print("Usage: hash.py files_to_compute_hash")
+ sys.exit(1)
+ files = [file for file in sys.argv[1:] if file[-3:].lower() != ".py"]
+ print_hashes(files)
diff --git a/wppm/packagemetadata.py b/wppm/packagemetadata.py
new file mode 100644
index 00000000..a51e8331
--- /dev/null
+++ b/wppm/packagemetadata.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+"""
+packagemetadata.py - get metadata from designated place
+"""
+import os
+import re
+import tarfile
+import zipfile
+import sys
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+from . import utils
+import importlib.metadata
+import email
+from packaging.utils import canonicalize_name
+# --- 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
+ pkgs = []
+ distro = importlib.metadata.distributions(path = path) if path else importlib.metadata.distributions()
+ for dist in distro:
+ name = canonicalize_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:
+ 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:
+ 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:
+ meta = email.message_from_string(txt)
+ name = canonicalize_name(meta.get('Name', ''))
+ version = meta.get('Version', '')
+ summary = meta.get('Summary', '')
+ description = meta.get('Description', '')
+ requires = meta.get_all('Requires-Dist') or []
+ return PackageMetadata(name, version, requires, summary, description, dict(meta.items()))
+
+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/wppm/piptree.py b/wppm/piptree.py
new file mode 100644
index 00000000..d6569b4d
--- /dev/null
+++ b/wppm/piptree.py
@@ -0,0 +1,290 @@
+# -*- coding: utf-8 -*-
+"""
+piptree.py: inspect and display Python package dependencies,
+supporting both downward and upward dependency trees.
+Requires Python 3.8+ due to importlib.metadata.
+"""
+
+import json
+import sys
+import re
+import platform
+import os
+import logging
+from functools import lru_cache
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple, Union
+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__)
+
+class PipDataError(Exception):
+ """Custom exception for PipData related errors."""
+ pass
+
+class PipData:
+ """Manages package metadata and dependency relationships in a Python environment."""
+
+ def __init__(self, target: Optional[str] = None, wheelhouse = None):
+ """
+ Initialize the PipData instance.
+
+ :param target: Optional target path to search for packages
+ """
+ self.distro: Dict[str, Dict] = {}
+ self.raw: Dict[str, Dict] = {}
+ self.environment = self._get_environment()
+ try:
+ packages = self._get_packages(target or sys.executable, wheelhouse)
+ self._process_packages(packages)
+ self._populate_reverse_dependencies()
+ except Exception as e:
+ raise PipDataError(f"Failed to initialize package data: {str(e)}") from e
+
+ @staticmethod
+ @lru_cache(maxsize=None)
+ def normalize(name: str) -> str:
+ """Normalize package name per PEP 503."""
+ return re.sub(r"[-_.]+", "-", name).lower()
+
+ def _get_environment(self) -> Dict[str, str]:
+ """Collect system and Python environment details."""
+ return {
+ "implementation_name": sys.implementation.name,
+ "implementation_version": f"{sys.implementation.version.major}.{sys.implementation.version.minor}.{sys.implementation.version.micro}",
+ "os_name": os.name,
+ "platform_machine": platform.machine(),
+ "platform_release": platform.release(),
+ "platform_system": platform.system(),
+ "platform_version": platform.version(),
+ "python_full_version": platform.python_version(),
+ "platform_python_implementation": platform.python_implementation(),
+ "python_version": ".".join(platform.python_version_tuple()[:2]),
+ "sys_platform": sys.platform,
+ }
+
+ 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 pm.get_installed_metadata() #Distribution.discover()
+ else:
+ 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."""
+ for package in packages:
+ try:
+ meta = package.metadata
+ name = meta.get('Name')
+ if not name:
+ continue
+ key = self.normalize(name)
+ self.raw[key] = meta
+ self.distro[key] = {
+ "name": name,
+ "version": package.version,
+ "summary": meta.get("Summary", ""),
+ "requires_dist": self._get_requires(package),
+ "reverse_dependencies": [],
+ "description": meta.get("Description", ""),
+ "provides": self._get_provides(package),
+ "provided": {'': None} # Placeholder for extras provided by this package
+ }
+ except Exception as e:
+ logger.warning(f"Failed to process package {name}: {str(e)}", exc_info=True)
+
+ def _get_requires(self, package: Distribution) -> List[Dict[str, str]]:
+ """Extract and normalize requirements for a package."""
+ requires = []
+ replacements = str.maketrans({" ": " ", "[": "", "]": "", "'": "", '"': ""})
+ further_replacements = [
+ (' == ', '=='), ('= ', '='), (' !=', '!='), (' ~=', '~='),
+ (' <', '<'), ('< ', '<'), (' >', '>'), ('> ', '>'),
+ ('; ', ';'), (' ;', ';'), ('( ', '('),
+ (' and (', ' andZZZZZ('), (' (', '('), (' andZZZZZ(', ' and (')
+ ]
+
+ if package.requires:
+ for req in package.requires:
+ req_nameextra, req_marker = (req + ";").split(";")[:2]
+ req_nameextra = self.normalize(re.split(r" |;|==|!|>|<|~=", req_nameextra + ";")[0])
+ req_key = self.normalize((req_nameextra + "[").split("[")[0])
+ req_key_extra = req_nameextra[len(req_key) + 1:].split("]")[0]
+ req_version = req[len(req_nameextra):].translate(replacements)
+
+ for old, new in further_replacements:
+ req_version = req_version.replace(old, new)
+
+ req_add = {
+ "req_key": req_key,
+ "req_version": req_version,
+ "req_extra": req_key_extra,
+ }
+ if req_marker != "":
+ req_add["req_marker"] = req_marker
+ requires.append(req_add)
+ return requires
+
+ def _get_provides(self, package: Distribution) -> Dict[str, None]:
+ """Extract provided extras from package requirements."""
+ provides = {'': None}
+ if package.requires:
+ for req in package.requires:
+ req_marker = (req + ";").split(";")[1]
+ if 'extra == ' in req_marker:
+ remove_list = {ord("'"): None, ord('"'): None}
+ provides[req_marker.split('extra == ')[1].translate(remove_list)] = None
+ return provides
+
+ def _populate_reverse_dependencies(self) -> None:
+ """Populate reverse dependencies."""
+ for pkg_key, pkg_data in self.distro.items():
+ for req in pkg_data["requires_dist"]:
+ target_key = req["req_key"]
+ if target_key in self.distro:
+ rev_dep = {"req_key": pkg_key, "req_version": req["req_version"], "req_extra": req["req_extra"]}
+ if "req_marker" in req:
+ rev_dep["req_marker"] = req["req_marker"]
+ if 'extra == ' in req["req_marker"]:
+ remove_list = {ord("'"): None, ord('"'): None}
+ self.distro[target_key]["provided"][req["req_marker"].split('extra == ')[1].translate(remove_list)] = None
+ self.distro[target_key]["reverse_dependencies"].append(rev_dep)
+
+ def _get_dependency_tree(self, package_name: str, extra: str = "", version_req: str = "", depth: int = 20, path: Optional[List[str]] = None, verbose: bool = False, upward: bool = False) -> List[List[str]]:
+ """Recursive function to build dependency tree."""
+ path = path or []
+ extras = extra.split(",")
+ pkg_key = self.normalize(package_name)
+ ret_all = []
+
+ full_name = f"{package_name}[{extra}]" if extra else package_name
+ if full_name in path:
+ logger.warning(f"Cycle detected: {' -> '.join(path + [full_name])}")
+ return []
+
+ pkg_data = self.distro[pkg_key]
+ if pkg_data and len(path) <= depth:
+ for extra in extras:
+ environment = {"extra": extra, **self.environment}
+ summary = f' {pkg_data["summary"]}' if verbose else ''
+ base_name = f'{package_name}[{extra}]' if extra else package_name
+ ret = [f'{base_name}=={pkg_data["version"]} {version_req}{summary}']
+
+ dependencies = pkg_data["requires_dist"] if not upward else pkg_data["reverse_dependencies"]
+
+ for dependency in dependencies:
+ if dependency["req_key"] in self.distro:
+ next_path = path + [base_name]
+ if upward:
+ up_req = (dependency.get("req_marker", "").split('extra == ')+[""])[1].strip("'\"")
+ if dependency["req_key"] in self.distro and dependency["req_key"]+"["+up_req+"]" not in path:
+ # upward dependancy taken if:
+ # - if extra "" demanded, and no marker from upward package: like pandas[] ==> numpy
+ # - or the extra is in the upward package, like pandas[test] ==> pytest, for 'test' extra
+ # - or an extra "array" is demanded, and indeed in the req_extra list: array,dataframe,diagnostics,distributer
+ if (not dependency.get("req_marker") and extra == "") or \
+ ("req_marker" in dependency and extra == up_req and \
+ dependency["req_key"] != pkg_key and \
+ Marker(dependency["req_marker"]).evaluate(environment=environment)) or \
+ ("req_marker" in dependency and extra != "" and \
+ extra + ',' in dependency["req_extra"] + ',' and \
+ Marker(dependency["req_marker"]).evaluate(environment=environment | {"extra": up_req})):
+ # IA risk error: # dask[array] go upwards as dask[dataframe], so {"extra": up_req} , not {"extra": extra}
+ #tag downward limiting dependancies
+ wall = " " if dependency["req_version"][:1] == "~" or dependency["req_version"].startswith("==") or "<" in dependency["req_version"] else ""
+ ret += self._get_dependency_tree(
+ dependency["req_key"],
+ up_req,
+ f"[requires{wall}: {package_name}"
+ + (f"[{dependency['req_extra']}]" if dependency["req_extra"] != "" else "")
+ + f'{dependency["req_version"]}]',
+ depth,
+ next_path,
+ verbose=verbose,
+ upward=upward,
+ )
+ elif not dependency.get("req_marker") or Marker(dependency["req_marker"]).evaluate(environment=environment):
+ ret += self._get_dependency_tree(
+ dependency["req_key"],
+ dependency["req_extra"],
+ dependency["req_version"],
+ depth,
+ next_path,
+ verbose=verbose,
+ upward=upward,
+ )
+
+ ret_all.append(ret)
+ return ret_all
+
+ def down(self, pp: str = "", extra: str = "", depth: int = 20, indent: int = 5, version_req: str = "", verbose: bool = False) -> str:
+ """Generate downward dependency tree as formatted string."""
+ if pp == ".":
+ results = [self.down(p, extra, depth, indent, version_req, verbose=verbose) for p in sorted(self.distro)]
+ return '\n'.join(filter(None, results))
+
+ if extra == ".":
+ if pp in self.distro:
+ results = [self.down(pp, one_extra, depth, indent, version_req, verbose=verbose)
+ for one_extra in sorted(self.distro[pp]["provides"])]
+ return '\n'.join(filter(None, results))
+ return ""
+
+ if pp not in self.distro:
+ return ""
+
+ rawtext = json.dumps(self._get_dependency_tree(pp, extra, version_req, depth, verbose=verbose), indent=indent)
+ lines = [l for l in rawtext.split("\n") if len(l.strip()) > 2]
+ return "\n".join(lines).replace('"', "")
+
+ def up(self, ppw: str, extra: str = "", depth: int = 20, indent: int = 5, version_req: str = "", verbose: bool = False) -> str:
+ """Generate upward dependency tree as formatted string."""
+ pp = ppw[:-1] if ppw.endswith('!') else ppw
+ ppend = "!" if ppw.endswith('!') else "" #show only downward limiting dependancies
+ if pp == ".":
+ results = [aa for p in sorted(self.distro) if '[requires' in (aa:=self.up(p + ppend, extra, depth, indent, version_req, verbose))]
+ return '\n'.join(filter(None, results))
+
+ if extra == ".":
+ if pp in self.distro:
+ extras = set(self.distro[pp]["provided"]).union(set(self.distro[pp]["provides"]))
+ results = [self.up(pp + ppend, e, depth, indent, version_req, verbose=verbose) for e in sorted(extras)]
+ return '\n'.join(filter(None, results))
+ return ""
+
+ if pp not in self.distro:
+ return ""
+
+ rawtext = json.dumps(self._get_dependency_tree(pp, extra, version_req, depth, verbose=verbose, upward=True), indent=indent)
+ lines = [l for l in rawtext.split("\n") if len(l.strip()) > 2 and ( ppend=="" or not "[requires:" in l)]
+ return "\n".join(filter(None, lines)).replace('"', "").replace('[requires :', '[requires:')
+
+ def description(self, pp: str) -> None:
+ """Return package description or None if not found."""
+ if pp in self.distro:
+ return print("\n".join(self.distro[pp]["description"].split(r"\n")))
+
+ def summary(self, pp: str) -> str:
+ """Return package summary or empty string if not found."""
+ if pp in self.distro:
+ return self.distro[pp]["summary"]
+ return ""
+
+ def pip_list(self, full: bool = False, max_length: int = 144) -> List[Tuple[str, Union[str, Tuple[str, str]]]]:
+ """List installed packages with optional details.
+
+ :param full: Whether to include the package version and summary
+ :param max_length: The maximum length for the summary
+ :return: List of tuples containing package information
+ """
+ pkgs = sorted(self.distro.items())
+ if full:
+ 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/wppm/utils.py b/wppm/utils.py
new file mode 100644
index 00000000..20f154e7
--- /dev/null
+++ b/wppm/utils.py
@@ -0,0 +1,351 @@
+# -*- 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()
+
+def zip_directory(folder_path, output_zip_path):
+ folder_path = Path(folder_path)
+ output_zip_path = Path(output_zip_path)
+
+ with zipfile.ZipFile(output_zip_path, 'w', compression=zipfile.ZIP_DEFLATED) as zipf:
+ for file in folder_path.rglob('*'):
+ if file.is_file():
+ arcname = file.relative_to(folder_path)
+ zipf.write(file, arcname)
+
+def find_7zip_executable() -> str:
+ """Locates the 7-Zip executable (7z.exe)."""
+ possible_program_files = [r"C:\Program Files", r"C:\Program Files (x86)", Path(sys.prefix).parent / "t"]
+ for base_dir in possible_program_files:
+ if (executable_path := Path(base_dir) / "7-Zip" / "7z.exe").is_file():
+ return str(executable_path)
+ raise RuntimeError("7ZIP is not installed on this computer.")
+
+def create_installer_7zip(origin, destination, filename_stem, installer_type: str = "exe", compression= "mx5"):
+ """Creates a WinPython installer using 7-Zip: "exe", "7z", "zip")"""
+ fullfilename = destination / (filename_stem + "." + installer_type)
+ if installer_type not in ["exe", "7z", "zip"]:
+ return
+ 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}" "{origin}" {sfx_option}'
+ print(f'Executing 7-Zip script: "{command}"')
+ try:
+ subprocess.run(command, shell=True, check=True, stderr=sys.stderr, stdout=sys.stderr)
+ except subprocess.CalledProcessError as e:
+ print(f"Error executing 7-Zip script: {e}", file=sys.stderr)
+
+def command_installer_7zip(origin, destination, filename_stem, create_installer: str = "exe"):
+ for commmand in create_installer.lower().replace("7zip",".exe").split('.'):
+ installer_type, compression = (commmand + "-").split("-")[:2]
+ create_installer_7zip(Path(origin), Path(destination), filename_stem, installer_type, compression)
+
+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/wppm/wheelhouse.py b/wppm/wheelhouse.py
new file mode 100644
index 00000000..7ed9e54f
--- /dev/null
+++ b/wppm/wheelhouse.py
@@ -0,0 +1,207 @@
+#!/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
+from . import packagemetadata as pm
+
+# 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/wppm/wppm.py b/wppm/wppm.py
new file mode 100644
index 00000000..a80d3961
--- /dev/null
+++ b/wppm/wppm.py
@@ -0,0 +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 wppm/__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, __version__
+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=f"WinPython Package Manager: handle a WinPython Distribution and its packages ({__version__})",
+ 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 wppm import wppm;dist=wppm.Distribution(r'{dist.target}');dist.patch_standard_packages('pip', to_movable=False)"
+ cmd_mov = rf"from wppm 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
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy