diff --git a/winpython/piptree.py b/winpython/piptree.py index 23a53ff3..a2fa5ee8 100644 --- a/winpython/piptree.py +++ b/winpython/piptree.py @@ -17,6 +17,7 @@ from pip._vendor.packaging.markers import Marker from importlib.metadata import Distribution, distributions from pathlib import Path +from winpython import utils logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -25,15 +26,6 @@ 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.""" @@ -287,5 +279,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..97f38e78 100644 --- a/winpython/utils.py +++ b/winpython/utils.py @@ -71,30 +71,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 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 get_special_folder_path(path_name): """Return special folder path.""" diff --git a/winpython/wheelhouse.py b/winpython/wheelhouse.py index 1653aded..87c6a9ac 100644 --- a/winpython/wheelhouse.py +++ b/winpython/wheelhouse.py @@ -2,13 +2,22 @@ """ 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 email import message_from_bytes +from email.parser import BytesParser +from email.policy import default +from . import utils + +from packaging.utils import canonicalize_name # Use tomllib if available (Python 3.11+), otherwise fall back to tomli try: @@ -183,6 +192,53 @@ def get_pylock_wheels(wheelhouse: Path, lockfile: Path, wheelorigin: Optional[Pa else: print(f"\n\n*** We can't install {filename} ! ***\n\n") +def extract_metadata_from_wheel(filepath: Path) -> Optional[Tuple[str, str, str]]: + "get metadata from a wheel package" + with zipfile.ZipFile(filepath, 'r') as z: + # Locate *.dist-info/METADATA file inside but not in a vendored directory (flit-core) + for name in z.namelist(): + if name.endswith(r'.dist-info/METADATA') and name.split("/")[1] == "METADATA": + with z.open(name) as meta_file: + metadata = BytesParser(policy=default).parse(meta_file) + name = canonicalize_name(str(metadata.get('Name', 'unknown'))) # Avoid Head type + version = str(metadata.get('Version', 'unknown')) + summary = utils.sum_up(str(metadata.get('Summary', ''))) + return name, version, summary + return None + +def extract_metadata_from_sdist(filepath: Path) -> Optional[Tuple[str, str, str]]: + "get metadata from a tar.gz or .zip package" + open_func = tarfile.open if filepath.suffixes[-2:] == ['.tar', '.gz'] else zipfile.ZipFile + with open_func(filepath, 'r') as archive: + namelist = archive.getnames() if isinstance(archive, tarfile.TarFile) else archive.namelist() + for name in namelist: + if name.endswith('PKG-INFO'): + content = archive.extractfile(name).read() if isinstance(archive, tarfile.TarFile) else archive.open(name).read() + metadata = message_from_bytes(content) + name = canonicalize_name(str(metadata.get('Name', 'unknown'))) # Avoid Head type + version = str(metadata.get('Version', 'unknown')) + summary = utils.sum_up(str(metadata.get('Summary', ''))) + return name, version, summary + return None + +def list_packages_with_metadata(directory: str) -> List[Tuple[str, str, str]]: + "get metadata from a Wheelhouse directory" + results = [] + for file in os.listdir(directory): + path = Path(directory) / file + try: + if path.suffix == '.whl': + meta = extract_metadata_from_wheel(path) + elif path.suffix == '.zip' or path.name.endswith('.tar.gz'): + meta = extract_metadata_from_sdist(path) + else: + continue + if meta: + results.append(meta) + except OSError: #Exception as e: # need to see it + print(f"Skipping {file}: {e}") + return results + def main() -> None: """Main entry point for the script.""" if len(sys.argv) != 2: diff --git a/winpython/wppm.py b/winpython/wppm.py index d6194829..a8c92abf 100644 --- a/winpython/wppm.py +++ b/winpython/wppm.py @@ -16,6 +16,7 @@ from argparse import ArgumentParser, RawTextHelpFormatter from winpython import utils, piptree, associate from winpython import wheelhouse as wh +from operator import itemgetter # Workaround for installing PyVISA on Windows from source: os.environ["HOME"] = os.environ["USERPROFILE"] @@ -23,7 +24,7 @@ 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.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..." @@ -81,8 +82,7 @@ def get_wheelhouse_packages_markdown(self) -> str: if wheeldir.is_dir(): package_lines = [ f"[{name}](https://pypi.org/project/{name}) | {version} | {summary}" - for name, version, summary in wh.list_packages_with_metadata(str(wheeldir)) - #for pkg in sorted(wh.list_packages_with_metadata(str(wheeldir)), key=lambda p: p.name.lower()) + for name, version, summary in sorted(wh.list_packages_with_metadata(str(wheeldir)), key=itemgetter(0 , 1)) # lambda p: p[0].lower()) ] return "\n".join(package_lines) return ""