diff --git a/relenv/__init__.py b/relenv/__init__.py index 2c93453f..7b80aedf 100644 --- a/relenv/__init__.py +++ b/relenv/__init__.py @@ -1,3 +1,15 @@ # Copyright 2025 Broadcom. # SPDX-License-Identifier: Apache-2 +from __future__ import annotations + +import sys + from relenv.common import __version__ + +MIN_SUPPORTED_PYTHON = (3, 10) + +if sys.version_info < MIN_SUPPORTED_PYTHON: + raise RuntimeError("Relenv requires Python 3.10 or newer.") + + +__all__ = ["__version__"] diff --git a/relenv/__main__.py b/relenv/__main__.py index c40fd31c..e6954604 100644 --- a/relenv/__main__.py +++ b/relenv/__main__.py @@ -4,13 +4,17 @@ The entrypoint into relenv. """ +from __future__ import annotations + +import argparse from argparse import ArgumentParser +from types import ModuleType from . import build, buildenv, check, create, fetch, pyversions, toolchain from .common import __version__ -def setup_cli(): +def setup_cli() -> ArgumentParser: """ Build the argparser with its subparsers. @@ -25,9 +29,11 @@ def setup_cli(): description="Relenv", ) argparser.add_argument("--version", action="version", version=__version__) - subparsers = argparser.add_subparsers() + subparsers: argparse._SubParsersAction[ + argparse.ArgumentParser + ] = argparser.add_subparsers() - modules_to_setup = [ + modules_to_setup: list[ModuleType] = [ build, toolchain, create, @@ -42,7 +48,7 @@ def setup_cli(): return argparser -def main(): +def main() -> None: """ Run the relenv cli and disbatch to subcommands. """ diff --git a/relenv/build/__init__.py b/relenv/build/__init__.py index 2320d967..32050e69 100644 --- a/relenv/build/__init__.py +++ b/relenv/build/__init__.py @@ -3,20 +3,22 @@ """ The ``relenv build`` command. """ -import sys -import random +from __future__ import annotations + +import argparse import codecs +import random import signal +import sys +from types import ModuleType -from . import linux, darwin, windows -from .common import builds, CHECK_VERSIONS_SUPPORT - -from ..pyversions import python_versions, Version - -from ..common import build_arch, DEFAULT_PYTHON +from . import darwin, linux, windows +from .common import CHECK_VERSIONS_SUPPORT, builds +from ..common import DEFAULT_PYTHON, build_arch +from ..pyversions import Version, python_versions -def platform_module(): +def platform_module() -> ModuleType: """ Return the right module based on `sys.platform`. """ @@ -26,9 +28,12 @@ def platform_module(): return linux elif sys.platform == "win32": return windows + raise RuntimeError(f"Unsupported platform: {sys.platform}") -def setup_parser(subparsers): +def setup_parser( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: """ Setup the subparser for the ``build`` command. @@ -124,7 +129,7 @@ def setup_parser(subparsers): ) -def main(args): +def main(args: argparse.Namespace) -> None: """ The entrypoint to the ``build`` command. diff --git a/relenv/build/common.py b/relenv/build/common.py index 7083c375..65764bb1 100644 --- a/relenv/build/common.py +++ b/relenv/build/common.py @@ -3,25 +3,39 @@ """ Build process common methods. """ +from __future__ import annotations + +import glob +import hashlib +import io import logging +import multiprocessing +import os import os.path -import hashlib import pathlib -import glob +import pprint +import random +import re import shutil -import tarfile -import tempfile -import time import subprocess -import random import sys -import io -import os -import multiprocessing -import pprint -import re +import tempfile +import time +import tarfile from html.parser import HTMLParser - +from types import ModuleType +from typing import ( + Any, + Callable, + Dict, + IO, + List, + MutableMapping, + Optional, + Sequence, + Tuple, + Union, +) from relenv.common import ( DATA_DIR, @@ -39,10 +53,14 @@ work_dirs, fetch_url, Version, + WorkDirs, ) import relenv.relocate +PathLike = Union[str, os.PathLike[str]] + + CHECK_VERSIONS_SUPPORT = True try: from packaging.version import InvalidVersion, parse @@ -131,7 +149,12 @@ def get_triplet(machine=None, plat=None): """ -def print_ui(events, processes, fails, flipstat=None): +def print_ui( + events: MutableMapping[str, "multiprocessing.synchronize.Event"], + processes: MutableMapping[str, multiprocessing.Process], + fails: Sequence[str], + flipstat: Optional[Dict[str, Tuple[int, float]]] = None, +) -> None: """ Prints the UI during the relenv building process. @@ -171,7 +194,7 @@ def print_ui(events, processes, fails, flipstat=None): sys.stdout.flush() -def verify_checksum(file, checksum): +def verify_checksum(file: PathLike, checksum: Optional[str]) -> bool: """ Verify the checksum of a files. @@ -197,7 +220,7 @@ def verify_checksum(file, checksum): return True -def all_dirs(root, recurse=True): +def all_dirs(root: PathLike, recurse: bool = True) -> List[str]: """ Get all directories under and including the given root. @@ -216,11 +239,11 @@ def all_dirs(root, recurse=True): return paths -def populate_env(dirs, env): +def populate_env(dirs: "Dirs", env: MutableMapping[str, str]) -> None: pass -def build_default(env, dirs, logfp): +def build_default(env: MutableMapping[str, str], dirs: "Dirs", logfp: IO[str]) -> None: """ The default build function if none is given during the build process. @@ -245,11 +268,18 @@ def build_default(env, dirs, logfp): runcmd(["make", "install"], env=env, stderr=logfp, stdout=logfp) -def build_openssl_fips(env, dirs, logfp): +def build_openssl_fips( + env: MutableMapping[str, str], dirs: "Dirs", logfp: IO[str] +) -> None: return build_openssl(env, dirs, logfp, fips=True) -def build_openssl(env, dirs, logfp, fips=False): +def build_openssl( + env: MutableMapping[str, str], + dirs: "Dirs", + logfp: IO[str], + fips: bool = False, +) -> None: """ Build openssl. @@ -313,7 +343,7 @@ def build_openssl(env, dirs, logfp, fips=False): runcmd(["make", "install_sw"], env=env, stderr=logfp, stdout=logfp) -def build_sqlite(env, dirs, logfp): +def build_sqlite(env: MutableMapping[str, str], dirs: "Dirs", logfp: IO[str]) -> None: """ Build sqlite. @@ -359,7 +389,7 @@ def build_sqlite(env, dirs, logfp): runcmd(["make", "install"], env=env, stderr=logfp, stdout=logfp) -def update_ensurepip(directory): +def update_ensurepip(directory: pathlib.Path) -> None: """ Update bundled dependencies for ensurepip (pip & setuptools). """ @@ -428,7 +458,7 @@ def update_ensurepip(directory): log.debug(init_file.read_text()) -def patch_file(path, old, new): +def patch_file(path: PathLike, old: str, new: str) -> None: """ Search a file line by line for a string to replace. @@ -452,7 +482,7 @@ def patch_file(path, old, new): fp.write(new_content) -def tarball_version(href): +def tarball_version(href: str) -> Optional[str]: if href.endswith("tar.gz"): try: x = href.split("-", 1)[1][:-7] @@ -462,33 +492,33 @@ def tarball_version(href): return None -def sqlite_version(href): +def sqlite_version(href: str) -> Optional[str]: if "releaselog" in href: link = href.split("/")[1][:-5] return "{:d}{:02d}{:02d}00".format(*[int(_) for _ in link.split("_")]) -def github_version(href): +def github_version(href: str) -> Optional[str]: if "tag/" in href: return href.split("/v")[-1] -def krb_version(href): +def krb_version(href: str) -> Optional[str]: if re.match(r"\d\.\d\d/", href): return href[:-1] -def python_version(href): +def python_version(href: str) -> Optional[str]: if re.match(r"(\d+\.)+\d/", href): return href[:-1] -def uuid_version(href): +def uuid_version(href: str) -> Optional[str]: if "download" in href and "latest" not in href: return href[:-16].rsplit("/")[-1].replace("libuuid-", "") -def parse_links(text): +def parse_links(text: str) -> List[str]: class HrefParser(HTMLParser): hrefs = [] @@ -503,7 +533,12 @@ def handle_starttag(self, tag, attrs): return parser.hrefs -def check_files(name, location, func, current): +def check_files( + name: str, + location: str, + func: Callable[[str], Optional[str]], + current: str, +) -> None: fp = io.BytesIO() fetch_url(location, fp) fp.seek(0) @@ -531,7 +566,7 @@ def check_files(name, location, func, current): compare_versions(name, current, versions) -def compare_versions(name, current, versions): +def compare_versions(name: str, current: Any, versions: Sequence[Any]) -> None: for version in versions: try: if version > current: @@ -561,16 +596,16 @@ class Download: def __init__( self, - name, - url, - fallback_url=None, - signature=None, - destination="", - version="", - checksum=None, - checkfunc=None, - checkurl=None, - ): + name: str, + url: str, + fallback_url: Optional[str] = None, + signature: Optional[str] = None, + destination: str = "", + version: str = "", + checksum: Optional[str] = None, + checkfunc: Optional[Callable[[str], Optional[str]]] = None, + checkurl: Optional[str] = None, + ) -> None: self.name = name self.url_tpl = url self.fallback_url_tpl = fallback_url @@ -581,7 +616,7 @@ def __init__( self.checkfunc = checkfunc self.checkurl = checkurl - def copy(self): + def copy(self) -> "Download": return Download( self.name, self.url_tpl, @@ -595,28 +630,31 @@ def copy(self): ) @property - def url(self): + def url(self) -> str: return self.url_tpl.format(version=self.version) @property - def fallback_url(self): + def fallback_url(self) -> Optional[str]: if self.fallback_url_tpl: return self.fallback_url_tpl.format(version=self.version) + return None @property - def signature_url(self): + def signature_url(self) -> str: + if self.signature_tpl is None: + raise RelenvException("Signature template not configured") return self.signature_tpl.format(version=self.version) @property - def filepath(self): + def filepath(self) -> pathlib.Path: _, name = self.url.rsplit("/", 1) return pathlib.Path(self.destination) / name @property - def formatted_url(self): + def formatted_url(self) -> str: return self.url.format(version=self.version) - def fetch_file(self): + def fetch_file(self) -> Tuple[str, bool]: """ Download the file. @@ -626,21 +664,22 @@ def fetch_file(self): try: return download_url(self.url, self.destination, CICD), True except Exception as exc: - if self.fallback_url: + fallback = self.fallback_url + if fallback: print(f"Download failed {self.url} ({exc}); trying fallback url") - return download_url(self.fallback_url, self.destination, CICD), True + return download_url(fallback, self.destination, CICD), True raise - def fetch_signature(self, version): + def fetch_signature(self, version: Optional[str] = None) -> Tuple[str, bool]: """ Download the file signature. :return: The path to the downloaded signature. :rtype: str """ - return download_url(self.signature_url, self.destination, CICD) + return download_url(self.signature_url, self.destination, CICD), True - def exists(self): + def exists(self) -> bool: """ True when the artifact already exists on disk. @@ -649,11 +688,11 @@ def exists(self): """ return self.filepath.exists() - def valid_hash(self): + def valid_hash(self) -> None: pass @staticmethod - def validate_signature(archive, signature): + def validate_signature(archive: PathLike, signature: Optional[PathLike]) -> bool: """ True when the archive's signature is valid. @@ -680,7 +719,7 @@ def validate_signature(archive, signature): return False @staticmethod - def validate_checksum(archive, checksum): + def validate_checksum(archive: PathLike, checksum: Optional[str]) -> bool: """ True when when the archive matches the sha1 hash. @@ -698,7 +737,12 @@ def validate_checksum(archive, checksum): log.error("sha1 validation failed on %s: %s", archive, exc) return False - def __call__(self, force_download=False, show_ui=False, exit_on_failure=False): + def __call__( + self, + force_download: bool = False, + show_ui: bool = False, + exit_on_failure: bool = False, + ) -> bool: """ Downloads the url and validates the signature and sha1 sum. @@ -740,7 +784,7 @@ def __call__(self, force_download=False, show_ui=False, exit_on_failure=False): sys.exit(1) return valid - def check_version(self): + def check_version(self) -> None: if self.checkurl: url = self.checkurl else: @@ -760,7 +804,7 @@ class Dirs: :type arch: str """ - def __init__(self, dirs, name, arch, version): + def __init__(self, dirs: WorkDirs, name: str, arch: str, version: str) -> None: # XXX name is the specific to a step where as everything # else here is generalized to the entire build self.name = name @@ -774,7 +818,7 @@ def __init__(self, dirs, name, arch, version): self.tmpbuild = tempfile.mkdtemp(prefix="{}_build".format(name)) @property - def toolchain(self): + def toolchain(self) -> Optional[pathlib.Path]: if sys.platform == "darwin": return get_toolchain(root=self.root) elif sys.platform == "win32": @@ -783,7 +827,7 @@ def toolchain(self): return get_toolchain(self.arch, self.root) @property - def _triplet(self): + def _triplet(self) -> str: if sys.platform == "darwin": return "{}-macos".format(self.arch) elif sys.platform == "win32": @@ -792,10 +836,10 @@ def _triplet(self): return "{}-linux-gnu".format(self.arch) @property - def prefix(self): + def prefix(self) -> pathlib.Path: return self.build / f"{self.version}-{self._triplet}" - def __getstate__(self): + def __getstate__(self) -> Dict[str, Any]: """ Return an object used for pickling. @@ -812,7 +856,7 @@ def __getstate__(self): "tmpbuild": self.tmpbuild, } - def __setstate__(self, state): + def __setstate__(self, state: Dict[str, Any]) -> None: """ Unwrap the object returned from unpickling. @@ -828,7 +872,7 @@ def __setstate__(self, state): self.build = state["build"] self.tmpbuild = state["tmpbuild"] - def to_dict(self): + def to_dict(self) -> Dict[str, Any]: """ Get a dictionary representation of the directories in this collection. @@ -854,10 +898,10 @@ class Builds: Collection of builds. """ - def __init__(self): - self.builds = {} + def __init__(self) -> None: + self.builds: Dict[str, "Builder"] = {} - def add(self, platform, *args, **kwargs): + def add(self, platform: str, *args: Any, **kwargs: Any) -> "Builder": if "builder" in kwargs: build = kwargs.pop("builder") if args or kwargs: @@ -896,15 +940,15 @@ class Builder: def __init__( self, - root=None, - recipies=None, - build_default=build_default, - populate_env=populate_env, - arch="x86_64", - version="", - ): + root: Optional[PathLike] = None, + recipies: Optional[Dict[str, Dict[str, Any]]] = None, + build_default: Callable[..., Any] = build_default, + populate_env: Callable[["Dirs", MutableMapping[str, str]], None] = populate_env, + arch: str = "x86_64", + version: str = "", + ) -> None: self.root = root - self.dirs = work_dirs(root) + self.dirs: WorkDirs = work_dirs(root) self.build_arch = build_arch() self.build_triplet = get_triplet(self.build_arch) self.arch = arch @@ -912,7 +956,7 @@ def __init__( self.downloads = self.dirs.download if recipies is None: - self.recipies = {} + self.recipies: Dict[str, Dict[str, Any]] = {} else: self.recipies = recipies @@ -922,7 +966,7 @@ def __init__( self.toolchains = get_toolchain(root=self.dirs.root) self.set_arch(self.arch) - def copy(self, version, checksum): + def copy(self, version: str, checksum: Optional[str]) -> "Builder": recipies = {} for name in self.recipies: _ = self.recipies[name] @@ -943,7 +987,7 @@ def copy(self, version, checksum): build.recipies["python"]["download"].checksum = checksum return build - def set_arch(self, arch): + def set_arch(self, arch: str) -> None: """ Set the architecture for the build. @@ -957,15 +1001,15 @@ def set_arch(self, arch): self.toolchain = get_toolchain(self.arch, self.dirs.root) @property - def triplet(self): + def triplet(self) -> str: return get_triplet(self.arch) @property - def prefix(self): + def prefix(self) -> pathlib.Path: return self.dirs.build / f"{self.version}-{self.triplet}" @property - def _triplet(self): + def _triplet(self) -> str: if sys.platform == "darwin": return "{}-macos".format(self.arch) elif sys.platform == "win32": @@ -973,7 +1017,13 @@ def _triplet(self): else: return "{}-linux-gnu".format(self.arch) - def add(self, name, build_func=None, wait_on=None, download=None): + def add( + self, + name: str, + build_func: Optional[Callable[..., Any]] = None, + wait_on: Optional[Sequence[str]] = None, + download: Optional[Dict[str, Any]] = None, + ) -> None: """ Add a step to the build process. @@ -999,8 +1049,14 @@ def add(self, name, build_func=None, wait_on=None, download=None): } def run( - self, name, event, build_func, download, show_ui=False, log_level="WARNING" - ): + self, + name: str, + event: "multiprocessing.synchronize.Event", + build_func: Callable[..., Any], + download: Optional[Download], + show_ui: bool = False, + log_level: str = "WARNING", + ) -> Any: """ Run a build step. @@ -1092,13 +1148,13 @@ def run( log.removeHandler(handler) logfp.close() - def cleanup(self): + def cleanup(self) -> None: """ Clean up the build directories. """ shutil.rmtree(self.prefix) - def clean(self): + def clean(self) -> None: """ Completely clean up the remnants of a relenv build. """ @@ -1118,7 +1174,12 @@ def clean(self): except FileNotFoundError: pass - def download_files(self, steps=None, force_download=False, show_ui=False): + def download_files( + self, + steps: Optional[Sequence[str]] = None, + force_download: bool = False, + show_ui: bool = False, + ) -> None: """ Download all of the needed archives. @@ -1178,7 +1239,13 @@ def download_files(self, steps=None, force_download=False, show_ui=False): sys.stderr.flush() sys.exit(1) - def build(self, steps=None, cleanup=True, show_ui=False, log_level="WARNING"): + def build( + self, + steps: Optional[Sequence[str]] = None, + cleanup: bool = True, + show_ui: bool = False, + log_level: str = "WARNING", + ) -> None: """ Build! @@ -1284,7 +1351,7 @@ def build(self, steps=None, cleanup=True, show_ui=False, log_level="WARNING"): log.debug("Performing cleanup.") self.cleanup() - def check_prereqs(self): + def check_prereqs(self) -> List[str]: """ Check pre-requsists for build. @@ -1303,15 +1370,15 @@ def check_prereqs(self): def __call__( self, - steps=None, - arch=None, - clean=True, - cleanup=True, - force_download=False, - download_only=False, - show_ui=False, - log_level="WARNING", - ): + steps: Optional[Sequence[str]] = None, + arch: Optional[str] = None, + clean: bool = True, + cleanup: bool = True, + force_download: bool = False, + download_only: bool = False, + show_ui: bool = False, + log_level: str = "WARNING", + ) -> None: """ Set the architecture, define the steps, clean if needed, download what is needed, and build. @@ -1373,7 +1440,7 @@ def __call__( return self.build(steps, cleanup, show_ui=show_ui, log_level=log_level) - def check_versions(self): + def check_versions(self) -> bool: success = True for step in list(self.recipies): download = self.recipies[step]["download"] @@ -1384,7 +1451,7 @@ def check_versions(self): return success -def patch_shebang(path, old, new): +def patch_shebang(path: PathLike, old: str, new: str) -> bool: """ Replace a file's shebang. @@ -1416,7 +1483,7 @@ def patch_shebang(path, old, new): return True -def patch_shebangs(path, old, new): +def patch_shebangs(path: PathLike, old: str, new: str) -> None: """ Traverse directory and patch shebangs. @@ -1432,7 +1499,12 @@ def patch_shebangs(path, old, new): patch_shebang(os.path.join(root, file), old, new) -def install_sysdata(mod, destfile, buildroot, toolchain): +def install_sysdata( + mod: ModuleType, + destfile: PathLike, + buildroot: PathLike, + toolchain: Optional[PathLike], +) -> None: """ Create a Relenv Python environment's sysconfigdata. @@ -1483,7 +1555,7 @@ def install_sysdata(mod, destfile, buildroot, toolchain): f.write(SYSCONFIGDATA) -def find_sysconfigdata(pymodules): +def find_sysconfigdata(pymodules: PathLike) -> str: """ Find sysconfigdata directory for python installation. @@ -1499,7 +1571,7 @@ def find_sysconfigdata(pymodules): return file[:-3] -def install_runtime(sitepackages): +def install_runtime(sitepackages: PathLike) -> None: """ Install a base relenv runtime. """ @@ -1525,7 +1597,11 @@ def install_runtime(sitepackages): wfp.write(rfp.read()) -def finalize(env, dirs, logfp): +def finalize( + env: MutableMapping[str, str], + dirs: Dirs, + logfp: IO[str], +) -> None: """ Run after we've fully built python. @@ -1676,7 +1752,12 @@ def runpip(pkg, upgrade=False): create_archive(fp, dirs.prefix, globs, logfp) -def create_archive(tarfp, toarchive, globs, logfp=None): +def create_archive( + tarfp: tarfile.TarFile, + toarchive: PathLike, + globs: Sequence[str], + logfp: Optional[IO[str]] = None, +) -> None: """ Create an archive. diff --git a/relenv/build/darwin.py b/relenv/build/darwin.py index a9eca639..9d1c0a9a 100644 --- a/relenv/build/darwin.py +++ b/relenv/build/darwin.py @@ -3,15 +3,18 @@ """ The darwin build process. """ +from __future__ import annotations + import io +from typing import IO, MutableMapping -from ..common import arches, DARWIN, MACOS_DEVELOPMENT_TARGET -from .common import runcmd, finalize, build_openssl, build_sqlite, builds +from ..common import DARWIN, MACOS_DEVELOPMENT_TARGET, arches +from .common import Dirs, build_openssl, build_sqlite, builds, finalize, runcmd ARCHES = arches[DARWIN] -def populate_env(env, dirs): +def populate_env(env: MutableMapping[str, str], dirs: Dirs) -> None: """ Make sure we have the correct environment variables set. @@ -34,7 +37,7 @@ def populate_env(env, dirs): env["CFLAGS"] = " ".join(cflags).format(prefix=dirs.prefix) -def build_python(env, dirs, logfp): +def build_python(env: MutableMapping[str, str], dirs: Dirs, logfp: IO[str]) -> None: """ Run the commands to build Python. diff --git a/relenv/build/linux.py b/relenv/build/linux.py index f1b636de..04a5d66f 100644 --- a/relenv/build/linux.py +++ b/relenv/build/linux.py @@ -3,14 +3,37 @@ """ The linux build process. """ +from __future__ import annotations + +import io +import os import pathlib +import shutil import tempfile -from .common import * -from ..common import arches, LINUX, Version +from typing import IO, MutableMapping + +from .common import ( + Dirs, + build_openssl, + build_openssl_fips, + build_sqlite, + builds, + finalize, + github_version, + runcmd, + sqlite_version, + tarball_version, + krb_version, + python_version, + uuid_version, +) +from ..common import LINUX, Version, arches ARCHES = arches[LINUX] +EnvMapping = MutableMapping[str, str] + # Patch for Python's setup.py PATCH = """--- ./setup.py +++ ./setup.py @@ -25,7 +48,7 @@ def add_multiarch_paths(self): """ -def populate_env(env, dirs): +def populate_env(env: EnvMapping, dirs: Dirs) -> None: """ Make sure we have the correct environment variables set. @@ -68,7 +91,7 @@ def populate_env(env, dirs): env["PKG_CONFIG_PATH"] = f"{dirs.prefix}/lib/pkgconfig" -def build_bzip2(env, dirs, logfp): +def build_bzip2(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Build bzip2. @@ -112,7 +135,7 @@ def build_bzip2(env, dirs, logfp): shutil.copy2("libbz2.so.1.0.8", os.path.join(dirs.prefix, "lib")) -def build_libxcrypt(env, dirs, logfp): +def build_libxcrypt(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Build libxcrypt. @@ -139,7 +162,7 @@ def build_libxcrypt(env, dirs, logfp): runcmd(["make", "install"], env=env, stderr=logfp, stdout=logfp) -def build_gdbm(env, dirs, logfp): +def build_gdbm(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Build gdbm. @@ -166,7 +189,7 @@ def build_gdbm(env, dirs, logfp): runcmd(["make", "install"], env=env, stderr=logfp, stdout=logfp) -def build_ncurses(env, dirs, logfp): +def build_ncurses(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Build ncurses. @@ -225,7 +248,7 @@ def build_ncurses(env, dirs, logfp): ) -def build_readline(env, dirs, logfp): +def build_readline(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Build readline library. @@ -251,7 +274,7 @@ def build_readline(env, dirs, logfp): runcmd(["make", "install"], env=env, stderr=logfp, stdout=logfp) -def build_libffi(env, dirs, logfp): +def build_libffi(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Build libffi. @@ -282,7 +305,7 @@ def build_libffi(env, dirs, logfp): runcmd(["make", "install"], env=env, stderr=logfp, stdout=logfp) -def build_zlib(env, dirs, logfp): +def build_zlib(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Build zlib. @@ -309,7 +332,7 @@ def build_zlib(env, dirs, logfp): runcmd(["make", "install"], env=env, stderr=logfp, stdout=logfp) -def build_krb(env, dirs, logfp): +def build_krb(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Build kerberos. @@ -342,7 +365,7 @@ def build_krb(env, dirs, logfp): runcmd(["make", "install"], env=env, stderr=logfp, stdout=logfp) -def build_python(env, dirs, logfp): +def build_python(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Run the commands to build Python. diff --git a/relenv/build/windows.py b/relenv/build/windows.py index 39d25361..71398aa7 100644 --- a/relenv/build/windows.py +++ b/relenv/build/windows.py @@ -3,6 +3,8 @@ """ The windows build process. """ +from __future__ import annotations + import glob import json import logging @@ -11,24 +13,28 @@ import shutil import sys import tarfile +from typing import IO, MutableMapping from .common import ( + Dirs, + MODULE_DIR, builds, create_archive, download_url, extract_archive, install_runtime, - MODULE_DIR, patch_file, runcmd, update_ensurepip, ) -from ..common import arches, WIN32, Version +from ..common import WIN32, Version, arches log = logging.getLogger(__name__) ARCHES = arches[WIN32] +EnvMapping = MutableMapping[str, str] + if sys.platform == WIN32: import ctypes @@ -36,7 +42,7 @@ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) -def populate_env(env, dirs): +def populate_env(env: EnvMapping, dirs: Dirs) -> None: """ Make sure we have the correct environment variables set. @@ -48,7 +54,7 @@ def populate_env(env, dirs): env["MSBUILDDISABLENODEREUSE"] = "1" -def update_props(source, old, new): +def update_props(source: pathlib.Path, old: str, new: str) -> None: """ Overwrite a dependency string for Windows PCBuild. @@ -63,7 +69,7 @@ def update_props(source, old, new): patch_file(source / "PCbuild" / "get_externals.bat", old, new) -def get_externals_source(externals_dir, url): +def get_externals_source(externals_dir: pathlib.Path, url: str) -> None: """ Download external source code dependency. @@ -80,7 +86,7 @@ def get_externals_source(externals_dir, url): log.exception("Failed to remove temporary file") -def get_externals_bin(source_root, url): +def get_externals_bin(source_root: pathlib.Path, url: str) -> None: """ Download external binary dependency. @@ -90,7 +96,7 @@ def get_externals_bin(source_root, url): pass -def update_sqlite(dirs, env): +def update_sqlite(dirs: Dirs, env: EnvMapping) -> None: """ Update the SQLITE library. """ @@ -122,7 +128,7 @@ def update_sqlite(dirs, env): json.dump(data, f, indent=2) -def update_xz(dirs, env): +def update_xz(dirs: Dirs, env: EnvMapping) -> None: """ Update the XZ library. """ @@ -158,7 +164,7 @@ def update_xz(dirs, env): json.dump(data, f, indent=2) -def update_expat(dirs, env): +def update_expat(dirs: Dirs, env: EnvMapping) -> None: """ Update the EXPAT library. """ @@ -286,7 +292,7 @@ def update_expat(dirs, env): json.dump(data, f, indent=2) -def update_expat_check(env): +def update_expat_check(env: EnvMapping) -> bool: """ Check if the given python version should get an updated libexpat. @@ -311,7 +317,7 @@ def update_expat_check(env): return False -def build_python(env, dirs, logfp): +def build_python(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Run the commands to build Python. @@ -447,7 +453,7 @@ def build_python(env, dirs, logfp): ) -def finalize(env, dirs, logfp): +def finalize(env: EnvMapping, dirs: Dirs, logfp: IO[str]) -> None: """ Finalize sitecustomize, relenv runtime, and pip for Windows. diff --git a/relenv/buildenv.py b/relenv/buildenv.py index 0af9a31b..c7fc006d 100644 --- a/relenv/buildenv.py +++ b/relenv/buildenv.py @@ -3,8 +3,12 @@ """ Helper for building libraries to install into a relenv environment. """ +from __future__ import annotations + +import argparse import json import logging +import os import sys from .common import ( @@ -17,7 +21,9 @@ log = logging.getLogger() -def setup_parser(subparsers): +def setup_parser( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: """ Setup the subparser for the ``relenv buildenv`` command. @@ -36,14 +42,16 @@ def setup_parser(subparsers): ) -def is_relenv(): +def is_relenv() -> bool: """ True when we are in a relenv environment. """ return hasattr(sys, "RELENV") -def buildenv(relenv_path=None): +def buildenv( + relenv_path: str | os.PathLike[str] | None = None, +) -> dict[str, str]: """ Relenv build environment variable mapping. """ @@ -90,7 +98,7 @@ def buildenv(relenv_path=None): return env -def main(args): +def main(args: argparse.Namespace) -> None: """ The entrypoint into the ``relenv buildenv`` command. diff --git a/relenv/check.py b/relenv/check.py index 5c3c35bd..8ccd40f1 100644 --- a/relenv/check.py +++ b/relenv/check.py @@ -3,6 +3,9 @@ """ Check the integrety of a relenv environment. """ +from __future__ import annotations + +import argparse import logging import pathlib import sys @@ -12,7 +15,9 @@ log = logging.getLogger() -def setup_parser(subparsers): +def setup_parser( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: """ Setup the subparser for the ``relenv check`` command. @@ -23,7 +28,7 @@ def setup_parser(subparsers): subparser.set_defaults(func=main) -def main(args): +def main(args: argparse.Namespace) -> None: """ The entrypoint into the ``relenv check`` command. diff --git a/relenv/common.py b/relenv/common.py index f731fc55..829257d2 100644 --- a/relenv/common.py +++ b/relenv/common.py @@ -3,6 +3,8 @@ """ Common classes and values used around relenv. """ +from __future__ import annotations + import http.client import logging import os @@ -16,6 +18,7 @@ import textwrap import threading import time +from typing import IO, Any, BinaryIO, Iterable, Mapping, Optional, Union # relenv package version __version__ = "0.21.2" @@ -101,14 +104,14 @@ class RelenvException(Exception): """ -def format_shebang(python, tpl=SHEBANG_TPL): +def format_shebang(python: str, tpl: str = SHEBANG_TPL) -> str: """ Return a formatted shebang. """ return tpl.format(python).strip() + "\n" -def build_arch(): +def build_arch() -> str: """ Return the current machine. """ @@ -116,7 +119,9 @@ def build_arch(): return machine.lower() -def work_root(root=None): +def work_root( + root: Optional[Union[str, os.PathLike[str]]] = None, +) -> pathlib.Path: """ Get the root directory that all other relenv working directories should be based on. @@ -133,7 +138,9 @@ def work_root(root=None): return base -def work_dir(name, root=None): +def work_dir( + name: str, root: Optional[Union[str, os.PathLike[str]]] = None +) -> pathlib.Path: """ Get the absolute path to the relenv working directory of the given name. @@ -161,17 +168,17 @@ class WorkDirs: :type root: str """ - def __init__(self, root): - self.root = root - self.data = DATA_DIR - self.toolchain_config = work_dir("toolchain", self.root) - self.toolchain = work_dir("toolchain", DATA_DIR) - self.build = work_dir("build", DATA_DIR) - self.src = work_dir("src", DATA_DIR) - self.logs = work_dir("logs", DATA_DIR) - self.download = work_dir("download", DATA_DIR) + def __init__(self: "WorkDirs", root: Union[str, os.PathLike[str]]) -> None: + self.root: pathlib.Path = pathlib.Path(root) + self.data: pathlib.Path = DATA_DIR + self.toolchain_config: pathlib.Path = work_dir("toolchain", self.root) + self.toolchain: pathlib.Path = work_dir("toolchain", DATA_DIR) + self.build: pathlib.Path = work_dir("build", DATA_DIR) + self.src: pathlib.Path = work_dir("src", DATA_DIR) + self.logs: pathlib.Path = work_dir("logs", DATA_DIR) + self.download: pathlib.Path = work_dir("download", DATA_DIR) - def __getstate__(self): + def __getstate__(self: "WorkDirs") -> dict[str, pathlib.Path]: """ Return an object used for pickling. @@ -187,7 +194,7 @@ def __getstate__(self): "download": self.download, } - def __setstate__(self, state): + def __setstate__(self: "WorkDirs", state: Mapping[str, pathlib.Path]) -> None: """ Unwrap the object returned from unpickling. @@ -203,7 +210,9 @@ def __setstate__(self, state): self.download = state["download"] -def work_dirs(root=None): +def work_dirs( + root: Optional[Union[str, os.PathLike[str]]] = None, +) -> WorkDirs: """ Returns a WorkDirs instance based on the given root. @@ -216,7 +225,10 @@ def work_dirs(root=None): return WorkDirs(work_root(root)) -def get_toolchain(arch=None, root=None): +def get_toolchain( + arch: Optional[str] = None, + root: Optional[Union[str, os.PathLike[str]]] = None, +) -> Optional[pathlib.Path]: """ Get a the toolchain directory, specific to the arch if supplied. @@ -250,7 +262,7 @@ def get_toolchain(arch=None, root=None): return TOOLCHAIN_PATH -def get_triplet(machine=None, plat=None): +def get_triplet(machine: Optional[str] = None, plat: Optional[str] = None) -> str: """ Get the target triplet for the specified machine and platform. @@ -280,7 +292,7 @@ def get_triplet(machine=None, plat=None): raise RelenvException(f"Unknown platform {plat}") -def plat_from_triplet(plat): +def plat_from_triplet(plat: str) -> str: """ Convert platform from build to the value of sys.platform. """ @@ -293,11 +305,11 @@ def plat_from_triplet(plat): raise RelenvException(f"Unkown platform {plat}") -def list_archived_builds(): +def list_archived_builds() -> list[tuple[str, str, str]]: """ Return a list of version, architecture and platforms for builds. """ - builds = [] + builds: list[tuple[str, str, str]] = [] dirs = work_dirs(DATA_DIR) for root, dirs, files in os.walk(dirs.build): for file in files: @@ -309,7 +321,7 @@ def list_archived_builds(): return builds -def archived_build(triplet=None): +def archived_build(triplet: Optional[str] = None) -> pathlib.Path: """ Finds a the location of an archived build. @@ -326,7 +338,9 @@ def archived_build(triplet=None): return dirs.build / archive -def extract_archive(to_dir, archive): +def extract_archive( + to_dir: Union[str, os.PathLike[str]], archive: Union[str, os.PathLike[str]] +) -> None: """ Extract an archive to a specific location. @@ -354,7 +368,7 @@ def extract_archive(to_dir, archive): t.extractall(to_dir) -def get_download_location(url, dest): +def get_download_location(url: str, dest: Union[str, os.PathLike[str]]) -> str: """ Get the full path to where the url will be downloaded to. @@ -369,7 +383,7 @@ def get_download_location(url, dest): return os.path.join(dest, os.path.basename(url)) -def check_url(url, timestamp=None, timeout=30): +def check_url(url: str, timestamp: Optional[float] = None, timeout: float = 30) -> bool: """ Check that the url returns a 200. """ @@ -400,7 +414,7 @@ def check_url(url, timestamp=None, timeout=30): return True -def fetch_url(url, fp, backoff=3, timeout=30): +def fetch_url(url: str, fp: BinaryIO, backoff: int = 3, timeout: float = 30) -> None: """ Fetch the contents of a url. @@ -447,7 +461,7 @@ def fetch_url(url, fp, backoff=3, timeout=30): log.info("Download complete %s", url) -def fetch_url_content(url, backoff=3, timeout=30): +def fetch_url_content(url: str, backoff: int = 3, timeout: float = 30) -> str: """ Fetch the contents of a url. @@ -503,7 +517,13 @@ def fetch_url_content(url, backoff=3, timeout=30): return fp.read().decode() -def download_url(url, dest, verbose=True, backoff=3, timeout=60): +def download_url( + url: str, + dest: Union[str, os.PathLike[str]], + verbose: bool = True, + backoff: int = 3, + timeout: float = 60, +) -> str: """ Download the url to the provided destination. @@ -524,9 +544,9 @@ def download_url(url, dest, verbose=True, backoff=3, timeout=60): local = get_download_location(url, dest) if verbose: log.debug(f"Downloading {url} -> {local}") - fout = open(local, "wb") try: - fetch_url(url, fout, backoff, timeout) + with open(local, "wb") as fout: + fetch_url(url, fout, backoff, timeout) except Exception as exc: if verbose: log.error("Unable to download: %s\n%s", url, exc) @@ -536,12 +556,11 @@ def download_url(url, dest, verbose=True, backoff=3, timeout=60): pass raise finally: - fout.close() log.debug(f"Finished downloading {url} -> {local}") return local -def runcmd(*args, **kwargs): +def runcmd(*args: Any, **kwargs: Any) -> subprocess.Popen[str]: """ Run a command. @@ -583,22 +602,27 @@ def runcmd(*args, **kwargs): else: - def enqueue_stream(stream, queue, type): + def enqueue_stream( + stream: IO[str], item_queue: "queue.Queue[tuple[int | str, str]]", kind: int + ) -> None: NOOP = object() for line in iter(stream.readline, NOOP): if line is NOOP or line == "": break if line: - queue.put((type, line)) - log.debug("stream close %r %r", type, line) + item_queue.put((kind, line)) + log.debug("stream close %r %r", kind, line) stream.close() - def enqueue_process(process, queue): + def enqueue_process( + process: subprocess.Popen[str], + item_queue: "queue.Queue[tuple[int | str, str]]", + ) -> None: process.wait() - queue.put(("x", "x")) + item_queue.put(("x", "")) p = subprocess.Popen(*args, **kwargs) - q = queue.Queue() + q: "queue.Queue[tuple[int | str, str]]" = queue.Queue() to = threading.Thread(target=enqueue_stream, args=(p.stdout, q, 1)) te = threading.Thread(target=enqueue_stream, args=(p.stderr, q, 2)) tp = threading.Thread(target=enqueue_process, args=(p, q)) @@ -626,7 +650,11 @@ def enqueue_process(process, queue): return p -def relative_interpreter(root_dir, scripts_dir, interpreter): +def relative_interpreter( + root_dir: Union[str, os.PathLike[str]], + scripts_dir: Union[str, os.PathLike[str]], + interpreter: Union[str, os.PathLike[str]], +) -> pathlib.Path: """ Return a relativized path to the given scripts_dir and interpreter. """ @@ -644,7 +672,7 @@ def relative_interpreter(root_dir, scripts_dir, interpreter): return relscripts / relinterp -def makepath(*paths): +def makepath(*paths: Union[str, os.PathLike[str]]) -> tuple[str, str]: """ Make a normalized path name from paths. """ @@ -656,7 +684,7 @@ def makepath(*paths): return dir, os.path.normcase(dir) -def addpackage(sitedir, name): +def addpackage(sitedir: str, name: Union[str, os.PathLike[str]]) -> list[str] | None: """ Add editable package to path. """ @@ -664,7 +692,7 @@ def addpackage(sitedir, name): import stat fullname = os.path.join(sitedir, name) - paths = [] + paths: list[str] = [] try: st = os.lstat(fullname) except OSError: @@ -710,11 +738,11 @@ def addpackage(sitedir, name): return paths -def sanitize_sys_path(sys_path_entries): +def sanitize_sys_path(sys_path_entries: Iterable[str]) -> list[str]: """ Sanitize `sys.path` to only include paths relative to the onedir environment. """ - __sys_path = [] + __sys_path: list[str] = [] __valid_path_prefixes = tuple( { pathlib.Path(sys.prefix).resolve(), @@ -735,6 +763,8 @@ def sanitize_sys_path(sys_path_entries): for known_path in __sys_path[:]: for _ in pathlib.Path(known_path).glob("__editable__.*.pth"): paths = addpackage(known_path, _) + if not paths: + continue for p in paths: if p not in __sys_path: __sys_path.append(p) @@ -746,11 +776,14 @@ class Version: Version comparisons. """ - def __init__(self, data): - self.major, self.minor, self.micro = self.parse_string(data) - self._data = data + def __init__(self, data: str) -> None: + major, minor, micro = self.parse_string(data) + self.major: int = major + self.minor: Optional[int] = minor + self.micro: Optional[int] = micro + self._data: str = data - def __str__(self): + def __str__(self: "Version") -> str: """ Version as string. """ @@ -762,7 +795,7 @@ def __str__(self): # XXX What if minor was None but micro was an int. return _ - def __hash__(self): + def __hash__(self: "Version") -> int: """ Hash of the version. @@ -771,7 +804,7 @@ def __hash__(self): return hash((self.major, self.minor, self.micro)) @staticmethod - def parse_string(data): + def parse_string(data: str) -> tuple[int, Optional[int], Optional[int]]: """ Parse a version string into major, minor, and micro integers. """ @@ -785,10 +818,12 @@ def parse_string(data): else: raise RuntimeError("Too many parts to parse") - def __eq__(self, other): + def __eq__(self: "Version", other: object) -> bool: """ Equality comparisons. """ + if not isinstance(other, Version): + return NotImplemented mymajor = 0 if self.major is None else self.major myminor = 0 if self.minor is None else self.minor mymicro = 0 if self.micro is None else self.micro @@ -797,10 +832,12 @@ def __eq__(self, other): micro = 0 if other.micro is None else other.micro return mymajor == major and myminor == minor and mymicro == micro - def __lt__(self, other): + def __lt__(self: "Version", other: object) -> bool: """ Less than comparrison. """ + if not isinstance(other, Version): + return NotImplemented mymajor = 0 if self.major is None else self.major myminor = 0 if self.minor is None else self.minor mymicro = 0 if self.micro is None else self.micro @@ -816,10 +853,12 @@ def __lt__(self, other): return True return False - def __le__(self, other): + def __le__(self: "Version", other: object) -> bool: """ Less than or equal to comparrison. """ + if not isinstance(other, Version): + return NotImplemented mymajor = 0 if self.major is None else self.major myminor = 0 if self.minor is None else self.minor mymicro = 0 if self.micro is None else self.micro @@ -832,14 +871,18 @@ def __le__(self, other): return True return False - def __gt__(self, other): + def __gt__(self: "Version", other: object) -> bool: """ Greater than comparrison. """ + if not isinstance(other, Version): + return NotImplemented return not self.__le__(other) - def __ge__(self, other): + def __ge__(self: "Version", other: object) -> bool: """ Greater than or equal to comparrison. """ + if not isinstance(other, Version): + return NotImplemented return not self.__lt__(other) diff --git a/relenv/create.py b/relenv/create.py index f80a8167..1d060db4 100644 --- a/relenv/create.py +++ b/relenv/create.py @@ -4,17 +4,21 @@ The ``relenv create`` command. """ +from __future__ import annotations + +import argparse import contextlib import os import pathlib import sys import tarfile +from collections.abc import Iterator from .common import RelenvException, arches, archived_build, build_arch @contextlib.contextmanager -def chdir(path): +def chdir(path: str | os.PathLike[str]) -> Iterator[None]: """ Context manager that changes to the specified directory and back. @@ -35,7 +39,9 @@ class CreateException(RelenvException): """ -def setup_parser(subparsers): +def setup_parser( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: """ Setup the subparser for the ``create`` command. @@ -66,7 +72,12 @@ def setup_parser(subparsers): ) -def create(name, dest=None, arch=None, version=None): +def create( + name: str, + dest: str | os.PathLike[str] | None = None, + arch: str | None = None, + version: str | None = None, +) -> None: """ Create a relenv environment. @@ -124,7 +135,7 @@ def create(name, dest=None, arch=None, version=None): fp.extract(f, writeto) -def main(args): +def main(args: argparse.Namespace) -> None: """ The entrypoint into the ``relenv create`` command. diff --git a/relenv/fetch.py b/relenv/fetch.py index 403e0201..4e0205e6 100644 --- a/relenv/fetch.py +++ b/relenv/fetch.py @@ -4,8 +4,12 @@ The ``relenv fetch`` command. """ +from __future__ import annotations + +import argparse import os import sys +from collections.abc import Sequence from .build import platform_module from .common import ( @@ -21,7 +25,9 @@ ) -def setup_parser(subparsers): +def setup_parser( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: """ Setup the subparser for the ``fetch`` command. @@ -46,7 +52,12 @@ def setup_parser(subparsers): ) -def fetch(version, triplet, python, check_hosts=CHECK_HOSTS): +def fetch( + version: str, + triplet: str, + python: str, + check_hosts: Sequence[str] = CHECK_HOSTS, +) -> None: """ Fetch the specified python build. """ @@ -66,7 +77,7 @@ def fetch(version, triplet, python, check_hosts=CHECK_HOSTS): download_url(url, builddir) -def main(args): +def main(args: argparse.Namespace) -> None: """ The entrypoint into the ``relenv fetch`` command. diff --git a/relenv/manifest.py b/relenv/manifest.py index 17e4ecb4..dc7983f0 100644 --- a/relenv/manifest.py +++ b/relenv/manifest.py @@ -4,22 +4,30 @@ """ Relenv manifest. """ +from __future__ import annotations + import hashlib import os +import pathlib import sys -def manifest(root=None): +def manifest(root: str | os.PathLike[str] | None = None) -> None: """ List all the file in a relenv and their hashes. """ - if root is None: - root = getattr(sys, "RELENV", os.getcwd()) - for root, dirs, files in os.walk(root): + base = ( + pathlib.Path(root) + if root is not None + else pathlib.Path(getattr(sys, "RELENV", os.getcwd())) + ) + for dirpath, _dirs, files in os.walk(base): + directory = pathlib.Path(dirpath) for file in files: hsh = hashlib.sha256() + file_path = directory / file try: - with open(root + os.path.sep + file, "rb") as fp: + with open(file_path, "rb") as fp: while True: chunk = fp.read(9062) if not chunk: @@ -27,7 +35,7 @@ def manifest(root=None): hsh.update(chunk) except OSError: pass - print(f"{root + os.path.sep + file} => {hsh.hexdigest()}") + print(f"{file_path} => {hsh.hexdigest()}") if __name__ == "__main__": diff --git a/relenv/pyversions.py b/relenv/pyversions.py index 03bbf05a..4359cf3a 100644 --- a/relenv/pyversions.py +++ b/relenv/pyversions.py @@ -11,6 +11,9 @@ # ) # +from __future__ import annotations + +import argparse import hashlib import json import logging @@ -20,6 +23,7 @@ import subprocess import sys import time +from typing import Any from relenv.common import Version, check_url, download_url, fetch_url_content @@ -34,16 +38,16 @@ ARCHIVE = "https://www.python.org/ftp/python/{version}/Python-{version}.{ext}" -def _ref_version(x): +def _ref_version(x: str) -> Version: _ = x.split("Python ", 1)[1].split("<", 1)[0] return Version(_) -def _ref_path(x): +def _ref_path(x: str) -> str: return x.split('href="')[1].split('"')[0] -def _release_urls(version, gzip=False): +def _release_urls(version: Version, gzip: bool = False) -> tuple[str, str | None]: if gzip: tarball = f"https://www.python.org/ftp/python/{version}/Python-{version}.tgz" else: @@ -54,7 +58,7 @@ def _release_urls(version, gzip=False): return tarball, f"{tarball}.asc" -def _receive_key(keyid, server): +def _receive_key(keyid: str, server: str) -> bool: proc = subprocess.run( ["gpg", "--keyserver", server, "--recv-keys", keyid], capture_output=True ) @@ -63,25 +67,28 @@ def _receive_key(keyid, server): return False -def _get_keyid(proc): +def _get_keyid(proc: subprocess.CompletedProcess[bytes]) -> str | None: try: err = proc.stderr.decode() return err.splitlines()[1].rsplit(" ", 1)[-1] except (AttributeError, IndexError): - return False + return None -def verify_signature(path, signature): +def verify_signature( + path: str | os.PathLike[str], + signature: str | os.PathLike[str], +) -> bool: """ Verify gpg signature. """ proc = subprocess.run(["gpg", "--verify", signature, path], capture_output=True) keyid = _get_keyid(proc) if proc.returncode == 0: - print(f"Valid signature {path} {keyid}") + print(f"Valid signature {path} {keyid or ''}") return True err = proc.stderr.decode() - if "No public key" in err: + if keyid and "No public key" in err: for server in KEYSERVERS: if _receive_key(keyid, server): print(f"found public key {keyid} on {server}") @@ -106,7 +113,7 @@ def verify_signature(path, signature): UPDATE = False -def digest(file): +def digest(file: str | os.PathLike[str]) -> str: """ SHA-256 digest of file. """ @@ -116,9 +123,9 @@ def digest(file): return hsh.hexdigest() -def _main(): +def _main() -> None: - pyversions = {"versions": []} + pyversions: dict[str, Any] = {"versions": []} vfile = pathlib.Path(".pyversions") cfile = pathlib.Path(".content") @@ -130,6 +137,8 @@ def _main(): content = fetch_url_content(url) cfile.write_text(content) tsfile.write_text(str(ts)) + pyversions = {"versions": []} + vfile.write_text(json.dumps(pyversions, indent=1)) elif CHECK: ts = int(tsfile.read_text()) if check_url(url, timestamp=ts): @@ -152,7 +161,7 @@ def _main(): versions = [_ for _ in parsed_versions if _.major >= 3] cwd = os.getcwd() - out = {} + out: dict[str, dict[str, str]] = {} for version in versions: if VERSION and Version(VERSION) != version: @@ -210,7 +219,7 @@ def _main(): vfile.write_text(json.dumps(out, indent=1)) -def create_pyversions(path): +def create_pyversions(path: pathlib.Path) -> None: """ Create python-versions.json file. """ @@ -222,7 +231,7 @@ def create_pyversions(path): versions = [_ for _ in parsed_versions if _.major >= 3] if path.exists(): - data = json.loads(path.read_text()) + data: dict[str, str] = json.loads(path.read_text()) else: data = {} @@ -256,13 +265,21 @@ def create_pyversions(path): path.write_text(json.dumps(data, indent=1)) -def python_versions(minor=None, create=False, update=False): +def python_versions( + minor: str | None = None, + *, + create: bool = False, + update: bool = False, +) -> dict[Version, str]: """ List python versions. """ packaged = pathlib.Path(__file__).parent / "python-versions.json" local = pathlib.Path("~/.local/relenv/python-versions.json") + if update: + create = True + if create: create_pyversions(packaged) @@ -279,10 +296,12 @@ def python_versions(minor=None, create=False, update=False): if minor: mv = Version(minor) versions = [_ for _ in versions if _.major == mv.major and _.minor == mv.minor] - return {_: pyversions[str(_)] for _ in versions} + return {version: pyversions[str(version)] for version in versions} -def setup_parser(subparsers): +def setup_parser( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: """ Setup the subparser for the ``versions`` command. @@ -316,7 +335,7 @@ def setup_parser(subparsers): ) -def main(args): +def main(args: argparse.Namespace) -> None: """ Versions utility main method. """ diff --git a/relenv/relocate.py b/relenv/relocate.py index 477e0202..0be1d83a 100755 --- a/relenv/relocate.py +++ b/relenv/relocate.py @@ -4,11 +4,14 @@ A script to ensure the proper rpaths are in place for the relenv environment. """ +from __future__ import annotations + import logging import os import pathlib import shutil import subprocess +from typing import Optional log = logging.getLogger(__name__) @@ -47,7 +50,7 @@ LC_RPATH = "LC_RPATH" -def is_macho(path): +def is_macho(path: str | os.PathLike[str]) -> bool: """ Determines whether the given file is a macho file. @@ -63,7 +66,7 @@ def is_macho(path): return magic in [b"\xcf\xfa\xed\xfe"] -def is_elf(path): +def is_elf(path: str | os.PathLike[str]) -> bool: """ Determines whether the given file is an ELF file. @@ -78,7 +81,7 @@ def is_elf(path): return magic == b"\x7f\x45\x4c\x46" -def parse_otool_l(stdout): +def parse_otool_l(stdout: str) -> dict[str, list[str | None]]: """ Parse the output of ``otool -l ``. @@ -89,9 +92,9 @@ def parse_otool_l(stdout): :rtype: dict """ in_cmd = False - cmd = None - name = None - data = {} + cmd: Optional[str] = None + name: Optional[str] = None + data: dict[str, list[str | None]] = {} for line in [x.strip() for x in stdout.split("\n")]: if not line: @@ -123,7 +126,7 @@ def parse_otool_l(stdout): return data -def parse_readelf_d(stdout): +def parse_readelf_d(stdout: str) -> list[str]: """ Parse the output of ``readelf -d ``. @@ -141,7 +144,7 @@ def parse_readelf_d(stdout): return [] -def parse_macho(path): +def parse_macho(path: str | os.PathLike[str]) -> dict[str, list[str | None]] | None: """ Run ``otool -l `` and return its parsed output. @@ -156,11 +159,11 @@ def parse_macho(path): ) stdout = proc.stdout.decode() if stdout.find("is not an object file") != -1: - return + return None return parse_otool_l(stdout) -def parse_rpath(path): +def parse_rpath(path: str | os.PathLike[str]) -> list[str]: """ Run ``readelf -d `` and return its parsed output. @@ -176,7 +179,11 @@ def parse_rpath(path): return parse_readelf_d(proc.stdout.decode()) -def handle_macho(path, root_dir, rpath_only): +def handle_macho( + path: str | os.PathLike[str], + root_dir: str | os.PathLike[str], + rpath_only: bool, +) -> dict[str, list[str | None]] | None: """ Ensure the given macho file has the correct rpath and is in th correct location. @@ -191,6 +198,8 @@ def handle_macho(path, root_dir, rpath_only): """ obj = parse_macho(path) log.info("Processing file %s %r", path, obj) + if not obj: + return None if LC_LOAD_DYLIB in obj: for x in obj[LC_LOAD_DYLIB]: if path.startswith("@"): @@ -216,7 +225,9 @@ def handle_macho(path, root_dir, rpath_only): return obj -def is_in_dir(filepath, directory): +def is_in_dir( + filepath: str | os.PathLike[str], directory: str | os.PathLike[str] +) -> bool: """ Determines whether a file is contained within a directory. @@ -231,7 +242,11 @@ def is_in_dir(filepath, directory): return os.path.realpath(filepath).startswith(os.path.realpath(directory) + os.sep) -def patch_rpath(path, new_rpath, only_relative=True): +def patch_rpath( + path: str | os.PathLike[str], + new_rpath: str, + only_relative: bool = True, +) -> str | bool: """ Patch the rpath of a given ELF file. @@ -266,7 +281,12 @@ def patch_rpath(path, new_rpath, only_relative=True): return ":".join(old_rpath) -def handle_elf(path, libs, rpath_only, root=None): +def handle_elf( + path: str | os.PathLike[str], + libs: str | os.PathLike[str], + rpath_only: bool, + root: str | os.PathLike[str] | None = None, +) -> None: """ Handle the parsing and pathcing of an ELF file. @@ -333,8 +353,12 @@ def handle_elf(path, libs, rpath_only, root=None): def main( - root, libs_dir=None, rpath_only=True, log_level="DEBUG", log_file_name="" -): + root: str | os.PathLike[str], + libs_dir: str | os.PathLike[str] | None = None, + rpath_only: bool = True, + log_level: str = "DEBUG", + log_file_name: str = "", +) -> None: """ The entrypoint into the relocate script. @@ -364,7 +388,7 @@ def main( libs_dir = pathlib.Path(root_dir, "lib") libs_dir = str(pathlib.Path(libs_dir).resolve()) rpath_only = rpath_only - processed = {} + processed: dict[str, dict[str, list[str | None]] | None] = {} found = True while found: found = False diff --git a/relenv/runtime.py b/relenv/runtime.py index f4b25178..53574ad2 100644 --- a/relenv/runtime.py +++ b/relenv/runtime.py @@ -10,6 +10,8 @@ gcc. This ensures when using pip any c dependencies are compiled against the proper glibc version. """ +from __future__ import annotations + import contextlib import ctypes import functools @@ -23,6 +25,22 @@ import sys import textwrap import warnings +from importlib.machinery import ModuleSpec +from types import ModuleType +from typing import ( + Any, + Callable, + Dict, + Iterable, + Iterator, + Optional, + Sequence, + Union, + cast, +) + +PathType = Union[str, os.PathLike[str]] +ConfigVars = Dict[str, str] # relenv.pth has a __file__ which is set to the path to site.py of the python # interpreter being used. We're using that to determine the proper @@ -32,7 +50,7 @@ # imports happen before our path munghing in site in wrapsitecustomize. -def path_import(name, path): +def path_import(name: str, path: PathType) -> ModuleType: """ Import module from a path. @@ -42,13 +60,15 @@ def path_import(name, path): import importlib.util spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Unable to load module {name} from {path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) sys.modules[name] = module return module -def common(): +def common() -> ModuleType: """ Late import relenv common. """ @@ -56,10 +76,10 @@ def common(): common.common = path_import( "relenv.common", str(pathlib.Path(__file__).parent / "common.py") ) - return common.common + return cast(ModuleType, common.common) -def relocate(): +def relocate() -> ModuleType: """ Late import relenv relocate. """ @@ -67,10 +87,10 @@ def relocate(): relocate.relocate = path_import( "relenv.relocate", str(pathlib.Path(__file__).parent / "relocate.py") ) - return relocate.relocate + return cast(ModuleType, relocate.relocate) -def buildenv(): +def buildenv() -> ModuleType: """ Late import relenv buildenv. """ @@ -78,10 +98,10 @@ def buildenv(): buildenv.buildenv = path_import( "relenv.buildenv", str(pathlib.Path(__file__).parent / "buildenv.py") ) - return buildenv.buildenv + return cast(ModuleType, buildenv.buildenv) -def get_major_version(): +def get_major_version() -> str: """ Current python major version. """ @@ -89,7 +109,7 @@ def get_major_version(): @contextlib.contextmanager -def pushd(new_dir): +def pushd(new_dir: PathType) -> Iterator[None]: """ Changedir context. """ @@ -101,7 +121,7 @@ def pushd(new_dir): os.chdir(old_dir) -def debug(string): +def debug(string: str) -> None: """ Prints the provided message if RELENV_DEBUG is truthy in the environment. @@ -113,7 +133,7 @@ def debug(string): sys.stdout.flush() -def relenv_root(): +def relenv_root() -> pathlib.Path: """ Return the relenv module root. """ @@ -126,7 +146,9 @@ def relenv_root(): return MODULE_DIR.parent.parent.parent.parent -def _build_shebang(func, *args, **kwargs): +def _build_shebang( + func: Callable[..., bytes], *args: Any, **kwargs: Any +) -> Callable[..., bytes]: """ Build a shebang to point to the proper location. @@ -135,10 +157,10 @@ def _build_shebang(func, *args, **kwargs): """ @functools.wraps(func) - def wrapped(self, *args, **kwargs): + def wrapped(self: Any, *args: Any, **kwargs: Any) -> bytes: scripts = pathlib.Path(self.target_dir) if TARGET.TARGET: - scripts = pathlib.Path(TARGET.PATH).absolute() / "bin" + scripts = pathlib.Path(_ensure_target_path()).absolute() / "bin" try: interpreter = common().relative_interpreter( sys.RELENV, scripts, pathlib.Path(sys.executable).resolve() @@ -156,13 +178,13 @@ def wrapped(self, *args, **kwargs): return wrapped -def get_config_var_wrapper(func): +def get_config_var_wrapper(func: Callable[[str], Any]) -> Callable[[str], Any]: """ Return a wrapper to resolve paths relative to the relenv root. """ @functools.wraps(func) - def wrapped(name): + def wrapped(name: str) -> Any: if name == "BINDIR": orig = func(name) if os.environ.get("RELENV_PIP_DIR"): @@ -179,13 +201,13 @@ def wrapped(name): return wrapped -_CONFIG_VARS_DEFAULTS = { +CONFIG_VARS_DEFAULTS: ConfigVars = { "AR": "ar", "CC": "gcc", "CFLAGS": "-Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall", "CPPFLAGS": "-I. -I./Include", "CXX": "g++", - "LIBDEST": "/usr/local/lib/python3.8", + "LIBDEST": "/usr/local/lib/python3.10", "SCRIPTDIR": "/usr/local/lib", "BLDSHARED": "gcc -shared", "LDFLAGS": "", @@ -193,10 +215,10 @@ def wrapped(name): "LDSHARED": "gcc -shared", } -_SYSTEM_CONFIG_VARS = None +_SYSTEM_CONFIG_VARS: Optional[ConfigVars] = None -def system_sysconfig(): +def system_sysconfig() -> ConfigVars: """ Read the system python's sysconfig values. @@ -220,25 +242,27 @@ def system_sysconfig(): _SYSTEM_CONFIG_VARS = json.loads(p.stdout.strip()) except json.JSONDecodeError: debug(f"Failed to load JSON from: {p.stdout.strip()}") - _SYSTEM_CONFIG_VARS = _CONFIG_VARS_DEFAULTS + _SYSTEM_CONFIG_VARS = CONFIG_VARS_DEFAULTS else: debug("System python not found") - _SYSTEM_CONFIG_VARS = _CONFIG_VARS_DEFAULTS + _SYSTEM_CONFIG_VARS = CONFIG_VARS_DEFAULTS return _SYSTEM_CONFIG_VARS -def get_config_vars_wrapper(func, mod): +def get_config_vars_wrapper( + func: Callable[..., ConfigVars], mod: ModuleType +) -> Callable[..., ConfigVars]: """ Return a wrapper to resolve paths relative to the relenv root. """ @functools.wraps(func) - def wrapped(*args): + def wrapped(*args: Any) -> ConfigVars: if sys.platform == "win32" or "RELENV_BUILDENV" in os.environ: return func(*args) - _CONFIG_VARS = func() - _SYSTEM_CONFIG_VARS = system_sysconfig() + config_vars = func() + system_config_vars = system_sysconfig() for name in [ "AR", "CC", @@ -252,20 +276,26 @@ def wrapped(*args): "LDCXXSHARED", "LDSHARED", ]: - _CONFIG_VARS[name] = _SYSTEM_CONFIG_VARS[name] - mod._CONFIG_VARS = _CONFIG_VARS + config_vars[name] = system_config_vars[name] + mod._CONFIG_VARS = config_vars return func(*args) return wrapped -def get_paths_wrapper(func, default_scheme): +def get_paths_wrapper( + func: Callable[..., Dict[str, str]], default_scheme: str +) -> Callable[..., Dict[str, str]]: """ Return a wrapper to resolve paths relative to the relenv root. """ @functools.wraps(func) - def wrapped(scheme=default_scheme, vars=None, expand=True): + def wrapped( + scheme: Optional[str] = default_scheme, + vars: Optional[Dict[str, str]] = None, + expand: bool = True, + ) -> Dict[str, str]: paths = func(scheme=scheme, vars=vars, expand=expand) if "RELENV_PIP_DIR" in os.environ: paths["scripts"] = str(relenv_root()) @@ -275,7 +305,7 @@ def wrapped(scheme=default_scheme, vars=None, expand=True): return wrapped -def finalize_options_wrapper(func): +def finalize_options_wrapper(func: Callable[..., Any]) -> Callable[..., Any]: """ Wrapper around build_ext.finalize_options. @@ -283,15 +313,15 @@ def finalize_options_wrapper(func): """ @functools.wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: Any, *args: Any, **kwargs: Any) -> None: func(self, *args, **kwargs) if "RELENV_BUILDENV" in os.environ: - self.include_dirs.append(f"{relenv_root()}/include") + self.include_dirs.append(str(relenv_root() / "include")) return wrapper -def install_wheel_wrapper(func): +def install_wheel_wrapper(func: Callable[..., Any]) -> Callable[..., Any]: """ Wrap pip's wheel install function. @@ -300,15 +330,15 @@ def install_wheel_wrapper(func): @functools.wraps(func) def wrapper( - name, - wheel_path, - scheme, - req_description, - pycompile, - warn_script_location, - direct_url, - requested, - ): + name: str, + wheel_path: PathType, + scheme: Any, + req_description: str, + pycompile: Any, + warn_script_location: Any, + direct_url: Any, + requested: Any, + ) -> Any: from zipfile import ZipFile from pip._internal.utils.wheel import parse_wheel @@ -348,7 +378,7 @@ def wrapper( return wrapper -def install_legacy_wrapper(func): +def install_legacy_wrapper(func: Callable[..., Any]) -> Callable[..., Any]: """ Wrap pip's legacy install function. @@ -359,21 +389,21 @@ def install_legacy_wrapper(func): @functools.wraps(func) def wrapper( - install_options, - global_options, - root, - home, - prefix, - use_user_site, - pycompile, - scheme, - setup_py_path, - isolated, - req_name, - build_env, - unpacked_source_directory, - req_description, - ): + install_options: Any, + global_options: Any, + root: Any, + home: Any, + prefix: Any, + use_user_site: Any, + pycompile: Any, + scheme: Any, + setup_py_path: Any, + isolated: Any, + req_name: Any, + build_env: Any, + unpacked_source_directory: Any, + req_description: Any, + ) -> Any: pkginfo = pathlib.Path(setup_py_path).parent / "PKG-INFO" with open(pkginfo) as fp: @@ -447,13 +477,19 @@ class Wrapper: Wrap methods of an imported module. """ - def __init__(self, module, wrapper, matcher="equals", _loading=False): + def __init__( + self, + module: str, + wrapper: Callable[[str], ModuleType], + matcher: str = "equals", + _loading: bool = False, + ) -> None: self.module = module self.wrapper = wrapper self.matcher = matcher self.loading = _loading - def matches(self, module): + def matches(self: "Wrapper", module: str) -> bool: """ Check if wrapper metches module being imported. """ @@ -461,7 +497,7 @@ def matches(self, module): return module.startswith(self.module) return self.module == module - def __call__(self, module_name): + def __call__(self: "Wrapper", module_name: str) -> ModuleType: """ Preform the wrapper operation. """ @@ -473,15 +509,24 @@ class RelenvImporter: Handle runtime wrapping of module methods. """ - def __init__(self, wrappers=None, _loads=None): + def __init__( + self, + wrappers: Optional[Iterable[Wrapper]] = None, + _loads: Optional[Dict[str, ModuleType]] = None, + ) -> None: if wrappers is None: wrappers = [] - self.wrappers = set(wrappers) + self.wrappers: set[Wrapper] = set(wrappers) if _loads is None: _loads = {} - self._loads = _loads - - def find_spec(self, module_name, package_path=None, target=None): + self._loads: Dict[str, ModuleType] = _loads + + def find_spec( + self: "RelenvImporter", + module_name: str, + package_path: Optional[Sequence[str]] = None, + target: Any = None, + ) -> Optional[ModuleSpec]: """ Find modules being imported. """ @@ -491,7 +536,11 @@ def find_spec(self, module_name, package_path=None, target=None): wrapper.loading = True return importlib.util.spec_from_loader(module_name, self) - def find_module(self, module_name, package_path=None): + def find_module( + self: "RelenvImporter", + module_name: str, + package_path: Optional[Sequence[str]] = None, + ) -> Optional["RelenvImporter"]: """ Find modules being imported. """ @@ -501,33 +550,36 @@ def find_module(self, module_name, package_path=None): wrapper.loading = True return self - def load_module(self, name): + def load_module(self: "RelenvImporter", name: str) -> ModuleType: """ Load an imported module. """ + mod: Optional[ModuleType] = None for wrapper in self.wrappers: if wrapper.matches(name): debug(f"RelenvImporter - load_module {name}") mod = wrapper(name) wrapper.loading = False break + if mod is None: + mod = importlib.import_module(name) sys.modules[name] = mod return mod - def create_module(self, spec): + def create_module(self: "RelenvImporter", spec: ModuleSpec) -> Optional[ModuleType]: """ Create the module via a spec. """ return self.load_module(spec.name) - def exec_module(self, module): + def exec_module(self: "RelenvImporter", module: ModuleType) -> None: """ Exec module noop. """ return None -def wrap_sysconfig(name): +def wrap_sysconfig(name: str) -> ModuleType: """ Sysconfig wrapper. """ @@ -545,7 +597,7 @@ def wrap_sysconfig(name): return mod -def wrap_pip_distlib_scripts(name): +def wrap_pip_distlib_scripts(name: str) -> ModuleType: """ pip.distlib.scripts wrapper. """ @@ -554,7 +606,7 @@ def wrap_pip_distlib_scripts(name): return mod -def wrap_distutils_command(name): +def wrap_distutils_command(name: str) -> ModuleType: """ distutils.command wrapper. """ @@ -565,7 +617,7 @@ def wrap_distutils_command(name): return mod -def wrap_pip_install_wheel(name): +def wrap_pip_install_wheel(name: str) -> ModuleType: """ pip._internal.operations.install.wheel wrapper. """ @@ -574,7 +626,7 @@ def wrap_pip_install_wheel(name): return mod -def wrap_pip_install_legacy(name): +def wrap_pip_install_legacy(name: str) -> ModuleType: """ pip._internal.operations.install.legacy wrapper. """ @@ -583,7 +635,7 @@ def wrap_pip_install_legacy(name): return mod -def set_env_if_not_set(name, value): +def set_env_if_not_set(name: str, value: str) -> None: """ Set an environment variable if not already set. @@ -600,15 +652,15 @@ def set_env_if_not_set(name, value): os.environ[name] = value -def wrap_pip_build_wheel(name): +def wrap_pip_build_wheel(name: str) -> ModuleType: """ pip._internal.operations.build wrapper. """ mod = importlib.import_module(name) - def wrap(func): + def wrap(func: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: if sys.platform != "linux": return func(*args, **kwargs) base_dir = common().DATA_DIR / "toolchain" @@ -639,21 +691,30 @@ class TARGET: Container for global pip target state. """ - TARGET = False - TARGET_PATH = None - IGNORE = False - INSTALL = False + TARGET: bool = False + PATH: Optional[str] = None + IGNORE: bool = False + INSTALL: bool = False + + +def _ensure_target_path() -> str: + """ + Return the stored target path, raising if it is unavailable. + """ + if TARGET.PATH is None: + raise RuntimeError("TARGET path requested but not initialized") + return TARGET.PATH -def wrap_cmd_install(name): +def wrap_cmd_install(name: str) -> ModuleType: """ Wrap pip install command to store target argument state. """ mod = importlib.import_module(name) - def wrap(func): + def wrap(func: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(func) - def wrapper(self, options, args): + def wrapper(self: Any, options: Any, args: Sequence[str]) -> Any: if not options.use_user_site: if options.target_dir: TARGET.TARGET = True @@ -665,9 +726,11 @@ def wrapper(self, options, args): mod.InstallCommand.run = wrap(mod.InstallCommand.run) - def wrap(func): + def wrap(func: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(func) - def wrapper(self, target_dir, target_temp_dir, upgrade): + def wrapper( + self: Any, target_dir: str, target_temp_dir: str, upgrade: bool + ) -> int: from pip._internal.cli.status_codes import SUCCESS return SUCCESS @@ -681,24 +744,30 @@ def wrapper(self, target_dir, target_temp_dir, upgrade): return mod -def wrap_locations(name): +def wrap_locations(name: str) -> ModuleType: """ Wrap pip locations to fix locations when installing with target. """ mod = importlib.import_module(name) - def wrap(func): + def wrap(func: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(func) def wrapper( - dist_name, user=False, home=None, root=None, isolated=False, prefix=None - ): + dist_name: str, + user: bool = False, + home: Optional[PathType] = None, + root: Optional[PathType] = None, + isolated: bool = False, + prefix: Optional[PathType] = None, + ) -> Any: scheme = func(dist_name, user, home, root, isolated, prefix) if TARGET.TARGET and TARGET.INSTALL: from pip._internal.models.scheme import Scheme + target_path = _ensure_target_path() scheme = Scheme( - platlib=TARGET.PATH, - purelib=TARGET.PATH, + platlib=target_path, + purelib=target_path, headers=scheme.headers, scripts=scheme.scripts, data=scheme.data, @@ -716,21 +785,21 @@ def wrapper( return mod -def wrap_req_command(name): +def wrap_req_command(name: str) -> ModuleType: """ Honor ignore installed option from pip cli. """ mod = importlib.import_module(name) - def wrap(func): + def wrap(func: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(func) def wrapper( - self, - options, - session, - target_python=None, - ignore_requires_python=None, - ): + self: Any, + options: Any, + session: Any, + target_python: Any = None, + ignore_requires_python: Any = None, + ) -> Any: if TARGET.TARGET: options.ignore_installed = TARGET.IGNORE return func(self, options, session, target_python, ignore_requires_python) @@ -743,29 +812,31 @@ def wrapper( return mod -def wrap_req_install(name): +def wrap_req_install(name: str) -> ModuleType: """ Honor ignore installed option from pip cli. """ mod = importlib.import_module(name) - def wrap(func): - if mod.InstallRequirement.install.__code__.co_argcount == 7: + def wrap(func: Callable[..., Any]) -> Callable[..., Any]: + argcount = mod.InstallRequirement.install.__code__.co_argcount + + if argcount == 7: @functools.wraps(func) def wrapper( - self, - root=None, - home=None, - prefix=None, - warn_script_location=True, - use_user_site=False, - pycompile=True, - ): + self: Any, + root: Optional[PathType] = None, + home: Optional[PathType] = None, + prefix: Optional[PathType] = None, + warn_script_location: bool = True, + use_user_site: bool = False, + pycompile: bool = True, + ) -> Any: try: if TARGET.TARGET: TARGET.INSTALL = True - home = TARGET.PATH + home = _ensure_target_path() return func( self, root, @@ -778,23 +849,25 @@ def wrapper( finally: TARGET.INSTALL = False - elif mod.InstallRequirement.install.__code__.co_argcount == 8: + return wrapper + + if argcount == 8: @functools.wraps(func) def wrapper( - self, - global_options=None, - root=None, - home=None, - prefix=None, - warn_script_location=True, - use_user_site=False, - pycompile=True, - ): + self: Any, + global_options: Any = None, + root: Optional[PathType] = None, + home: Optional[PathType] = None, + prefix: Optional[PathType] = None, + warn_script_location: bool = True, + use_user_site: bool = False, + pycompile: bool = True, + ) -> Any: try: if TARGET.TARGET: TARGET.INSTALL = True - home = TARGET.PATH + home = _ensure_target_path() return func( self, global_options, @@ -808,25 +881,26 @@ def wrapper( finally: TARGET.INSTALL = False - else: - # Oldest version of this method sigature with 9 arguments. + return wrapper + + if argcount == 9: @functools.wraps(func) def wrapper( - self, - install_options, - global_options=None, - root=None, - home=None, - prefix=None, - warn_script_location=True, - use_user_site=False, - pycompile=True, - ): + self: Any, + install_options: Any, + global_options: Any = None, + root: Optional[PathType] = None, + home: Optional[PathType] = None, + prefix: Optional[PathType] = None, + warn_script_location: bool = True, + use_user_site: bool = False, + pycompile: bool = True, + ) -> Any: try: if TARGET.TARGET: TARGET.INSTALL = True - home = TARGET.PATH + home = _ensure_target_path() return func( self, install_options, @@ -841,6 +915,36 @@ def wrapper( finally: TARGET.INSTALL = False + return wrapper + + @functools.wraps(func) + def wrapper( + self: Any, + global_options: Any = None, + root: Optional[PathType] = None, + home: Optional[PathType] = None, + prefix: Optional[PathType] = None, + warn_script_location: bool = True, + use_user_site: bool = False, + pycompile: bool = True, + ) -> Any: + try: + if TARGET.TARGET: + TARGET.INSTALL = True + home = _ensure_target_path() + return func( + self, + global_options, + root, + home, + prefix, + warn_script_location, + use_user_site, + pycompile, + ) + finally: + TARGET.INSTALL = False + return wrapper mod.InstallRequirement.install = wrap(mod.InstallRequirement.install) @@ -863,7 +967,7 @@ def wrapper( ) -def install_cargo_config(): +def install_cargo_config() -> None: """ Setup cargo config. """ @@ -907,7 +1011,7 @@ def install_cargo_config(): ) -def setup_openssl(): +def setup_openssl() -> None: """ Configure openssl certificate locations. """ @@ -947,7 +1051,7 @@ def setup_openssl(): debug(msg) else: try: - _, directory = proc.stdout.split(":") + _, directory = proc.stdout.split(":", 1) except ValueError: debug("Unable to parse modules dir") return @@ -981,7 +1085,7 @@ def setup_openssl(): debug(msg) else: try: - _, directory = proc.stdout.split(":") + _, directory = proc.stdout.split(":", 1) except ValueError: debug("Unable to parse openssldir") return @@ -993,7 +1097,7 @@ def setup_openssl(): os.environ["SSL_CERT_FILE"] = str(cert_file) -def set_openssl_modules_dir(path): +def set_openssl_modules_dir(path: str) -> None: """ Set the default search location for openssl modules. """ @@ -1011,7 +1115,7 @@ def set_openssl_modules_dir(path): OSSL_PROVIDER_set_default_search_path(None, path.encode()) -def load_openssl_provider(name): +def load_openssl_provider(name: str) -> int: """ Load an openssl module. """ @@ -1027,7 +1131,7 @@ def load_openssl_provider(name): return OSSL_PROVIDER_load(None, name.encode()) -def setup_crossroot(): +def setup_crossroot() -> None: """ Setup cross root if needed. """ @@ -1045,7 +1149,7 @@ def setup_crossroot(): ] + [_ for _ in sys.path if "site-packages" not in _] -def wrapsitecustomize(func): +def wrapsitecustomize(func: Callable[[], Any]) -> Callable[[], None]: """ Wrap site.execsitecustomize. @@ -1054,7 +1158,7 @@ def wrapsitecustomize(func): """ @functools.wraps(func) - def wrapper(): + def wrapper() -> None: func() sitecustomize = None @@ -1083,7 +1187,7 @@ def wrapper(): return wrapper -def bootstrap(): +def bootstrap() -> None: """ Bootstrap the relenv environment. """ diff --git a/relenv/toolchain.py b/relenv/toolchain.py index 91650dde..789b8a18 100644 --- a/relenv/toolchain.py +++ b/relenv/toolchain.py @@ -3,10 +3,16 @@ """ The ``relenv toolchain`` command. """ + +from __future__ import annotations + +import argparse import sys -def setup_parser(subparsers): +def setup_parser( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], +) -> None: """ Setup the subparser for the ``toolchain`` command. @@ -17,7 +23,7 @@ def setup_parser(subparsers): subparser.set_defaults(func=main) -def main(*args, **kwargs): +def main(*args: object, **kwargs: object) -> None: """ Notify users of toolchain command deprecation. """ diff --git a/tests/test_common.py b/tests/test_common.py index 60be74e7..921724a5 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -1,27 +1,38 @@ # Copyright 2022-2025 Broadcom. # SPDX-License-Identifier: Apache-2 +from __future__ import annotations + import os import pathlib +import pickle import platform import shutil import subprocess import sys import tarfile +from types import ModuleType +from typing import BinaryIO from unittest.mock import patch import pytest +import relenv.common from relenv.common import ( MODULE_DIR, SHEBANG_TPL_LINUX, SHEBANG_TPL_MACOS, RelenvException, + Version, + addpackage, archived_build, + download_url, extract_archive, format_shebang, get_download_location, get_toolchain, get_triplet, + list_archived_builds, + makepath, relative_interpreter, runcmd, sanitize_sys_path, @@ -31,19 +42,41 @@ ) -def test_get_triplet_linux(): +def _mock_ppbt_module(monkeypatch: pytest.MonkeyPatch, triplet: str) -> None: + """ + Provide a lightweight ppbt.common stub so get_toolchain() skips the real extraction. + """ + stub_package = ModuleType("ppbt") + stub_common = ModuleType("ppbt.common") + stub_package.common = stub_common # type: ignore[attr-defined] + + # pytest will clean these entries up automatically via monkeypatch + monkeypatch.setitem(sys.modules, "ppbt", stub_package) + monkeypatch.setitem(sys.modules, "ppbt.common", stub_common) + + stub_common.ARCHIVE = pathlib.Path("dummy-toolchain.tar.xz") + + def fake_extract_archive(dest: str, archive: str) -> None: + dest_path = pathlib.Path(dest) + dest_path.mkdir(parents=True, exist_ok=True) + (dest_path / triplet).mkdir(parents=True, exist_ok=True) + + stub_common.extract_archive = fake_extract_archive # type: ignore[attr-defined] + + +def test_get_triplet_linux() -> None: assert get_triplet("aarch64", "linux") == "aarch64-linux-gnu" -def test_get_triplet_darwin(): +def test_get_triplet_darwin() -> None: assert get_triplet("x86_64", "darwin") == "x86_64-macos" -def test_get_triplet_windows(): +def test_get_triplet_windows() -> None: assert get_triplet("amd64", "win32") == "amd64-win" -def test_get_triplet_default(): +def test_get_triplet_default() -> None: machine = platform.machine().lower() plat = sys.platform if plat == "win32": @@ -56,12 +89,12 @@ def test_get_triplet_default(): pytest.fail(f"Do not know how to test for '{plat}' platform") -def test_get_triplet_unknown(): +def test_get_triplet_unknown() -> None: with pytest.raises(RelenvException): get_triplet("aarch64", "oijfsdf") -def test_archived_build(): +def test_archived_build() -> None: dirs = work_dirs() build = archived_build() try: @@ -70,23 +103,23 @@ def test_archived_build(): pytest.fail("Archived build value not relative to build dir") -def test_work_root_when_passed_relative_path(): +def test_work_root_when_passed_relative_path() -> None: name = "foo" assert work_root(name) == pathlib.Path(name).resolve() -def test_work_root_when_passed_full_path(): +def test_work_root_when_passed_full_path() -> None: name = "/foo/bar" if sys.platform == "win32": name = "D:/foo/bar" assert work_root(name) == pathlib.Path(name) -def test_work_root_when_nothing_passed(): +def test_work_root_when_nothing_passed() -> None: assert work_root() == MODULE_DIR -def test_work_dirs_attributes(): +def test_work_dirs_attributes() -> None: dirs = work_dirs() checkfor = [ "root", @@ -100,69 +133,125 @@ def test_work_dirs_attributes(): assert hasattr(dirs, attr) -def test_runcmd_success(): +def test_runcmd_success() -> None: ret = runcmd(["echo", "foo"]) assert ret.returncode == 0 -def test_runcmd_fail(): +def test_runcmd_fail() -> None: with pytest.raises(RelenvException): runcmd([sys.executable, "-c", "import sys;sys.exit(1)"]) -def test_work_dir_with_root_module_dir(): +def test_work_dir_with_root_module_dir() -> None: ret = work_dir("fakedir") assert ret == MODULE_DIR / "_fakedir" -def test_work_dir_with_root_given(tmp_path): +def test_work_dir_with_root_given(tmp_path: pathlib.Path) -> None: ret = work_dir("fakedir", root=tmp_path) assert ret == tmp_path / "fakedir" -def test_get_toolchain(tmp_path): +def test_get_toolchain(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: data_dir = tmp_path / "data" - with patch("relenv.common.DATA_DIR", data_dir): - ret = get_toolchain(arch="aarch64") - if sys.platform in ["darwin", "win32"]: - assert "data" in str(ret) - else: - assert f"{data_dir}/toolchain" in str(ret) + triplet = "aarch64-linux-gnu" + monkeypatch.setattr(relenv.common, "DATA_DIR", data_dir, raising=False) + monkeypatch.setattr(relenv.common.sys, "platform", "linux", raising=False) + monkeypatch.setattr(relenv.common, "get_triplet", lambda: triplet) + _mock_ppbt_module(monkeypatch, triplet) + ret = get_toolchain(arch="aarch64") + assert ret == data_dir / "toolchain" / triplet -def test_get_toolchain_no_arch(tmp_path): +def test_get_toolchain_linux_existing(tmp_path: pathlib.Path) -> None: data_dir = tmp_path / "data" - with patch("relenv.common.DATA_DIR", data_dir): + triplet = "x86_64-linux-gnu" + toolchain_path = data_dir / "toolchain" / triplet + toolchain_path.mkdir(parents=True) + with patch("relenv.common.DATA_DIR", data_dir), patch( + "relenv.common.sys.platform", "linux" + ), patch("relenv.common.get_triplet", return_value=triplet): ret = get_toolchain() - if sys.platform in ["darwin", "win32"]: - assert "data" in str(ret) - else: - assert f"{data_dir}/toolchain" in str(ret) + assert ret == toolchain_path -@pytest.mark.parametrize("open_arg", (":gz", ":xz", ":bz2", "")) -def test_extract_archive(tmp_path, open_arg): +def test_get_toolchain_no_arch( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + data_dir = tmp_path / "data" + triplet = "x86_64-linux-gnu" + monkeypatch.setattr(relenv.common, "DATA_DIR", data_dir, raising=False) + monkeypatch.setattr(relenv.common.sys, "platform", "linux", raising=False) + monkeypatch.setattr(relenv.common, "get_triplet", lambda: triplet) + _mock_ppbt_module(monkeypatch, triplet) + ret = get_toolchain() + assert ret == data_dir / "toolchain" / triplet + + +@pytest.mark.parametrize( + ("suffix", "mode"), + ( + (".tgz", "w:gz"), + (".tar.gz", "w:gz"), + (".tar.xz", "w:xz"), + (".tar.bz2", "w:bz2"), + (".tar", "w"), + ), +) +def test_extract_archive(tmp_path: pathlib.Path, suffix: str, mode: str) -> None: to_be_archived = tmp_path / "to_be_archived" to_be_archived.mkdir() test_file = to_be_archived / "testfile" test_file.touch() - tar_file = tmp_path / "fake_archive" + tar_file = tmp_path / f"fake_archive{suffix}" to_dir = tmp_path / "extracted" - with tarfile.open(str(tar_file), "w{}".format(open_arg)) as tar: + with tarfile.open(str(tar_file), mode) as tar: tar.add(str(to_be_archived), to_be_archived.name) extract_archive(str(to_dir), str(tar_file)) assert to_dir.exists() assert (to_dir / to_be_archived.name / test_file.name) in to_dir.glob("**/*") -def test_get_download_location(tmp_path): +def test_get_download_location(tmp_path: pathlib.Path) -> None: url = "https://test.com/1.0.0/test-1.0.0.tar.xz" loc = get_download_location(url, str(tmp_path)) assert loc == str(tmp_path / "test-1.0.0.tar.xz") +def test_download_url_writes_file(tmp_path: pathlib.Path) -> None: + dest = tmp_path / "downloads" + dest.mkdir() + data = b"payload" + + def fake_fetch(url: str, fp: BinaryIO, backoff: int, timeout: float) -> None: + fp.write(data) + + with patch("relenv.common.fetch_url", side_effect=fake_fetch): + path = download_url("https://example.com/a.txt", dest) + + assert pathlib.Path(path).read_bytes() == data + + +def test_download_url_failure_cleans_up(tmp_path: pathlib.Path) -> None: + dest = tmp_path / "downloads" + dest.mkdir() + created = dest / "a.txt" + + def fake_fetch(url: str, fp: BinaryIO, backoff: int, timeout: float) -> None: + raise RelenvException("fail") + + with patch("relenv.common.get_download_location", return_value=str(created)), patch( + "relenv.common.fetch_url", side_effect=fake_fetch + ), patch("relenv.common.log") as log_mock: + with pytest.raises(RelenvException): + download_url("https://example.com/a.txt", dest) + log_mock.error.assert_called() + assert not created.exists() + + @pytest.mark.skipif(shutil.which("shellcheck") is None, reason="Test needs shellcheck") -def test_shebang_tpl_linux(): +def test_shebang_tpl_linux() -> None: sh = format_shebang("python3", SHEBANG_TPL_LINUX).split("'''")[1].strip("'") proc = subprocess.Popen(["shellcheck", "-s", "sh", "-"], stdin=subprocess.PIPE) proc.stdin.write(sh.encode()) @@ -171,7 +260,7 @@ def test_shebang_tpl_linux(): @pytest.mark.skipif(shutil.which("shellcheck") is None, reason="Test needs shellcheck") -def test_shebang_tpl_macos(): +def test_shebang_tpl_macos() -> None: sh = format_shebang("python3", SHEBANG_TPL_MACOS).split("'''")[1].strip("'") proc = subprocess.Popen(["shellcheck", "-s", "sh", "-"], stdin=subprocess.PIPE) proc.stdin.write(sh.encode()) @@ -179,39 +268,39 @@ def test_shebang_tpl_macos(): assert proc.returncode == 0 -def test_format_shebang_newline(): +def test_format_shebang_newline() -> None: assert format_shebang("python3", SHEBANG_TPL_LINUX).endswith("\n") -def test_relative_interpreter_default_location(): +def test_relative_interpreter_default_location() -> None: assert relative_interpreter( "/tmp/relenv", "/tmp/relenv/bin", "/tmp/relenv/bin/python3" ) == pathlib.Path("..", "bin", "python3") -def test_relative_interpreter_pip_dir_location(): +def test_relative_interpreter_pip_dir_location() -> None: assert relative_interpreter( "/tmp/relenv", "/tmp/relenv", "/tmp/relenv/bin/python3" ) == pathlib.Path("bin", "python3") -def test_relative_interpreter_alternate_location(): +def test_relative_interpreter_alternate_location() -> None: assert relative_interpreter( "/tmp/relenv", "/tmp/relenv/bar/bin", "/tmp/relenv/bin/python3" ) == pathlib.Path("..", "..", "bin", "python3") -def test_relative_interpreter_interpreter_not_relative_to_root(): +def test_relative_interpreter_interpreter_not_relative_to_root() -> None: with pytest.raises(ValueError): relative_interpreter("/tmp/relenv", "/tmp/relenv/bar/bin", "/tmp/bin/python3") -def test_relative_interpreter_scripts_not_relative_to_root(): +def test_relative_interpreter_scripts_not_relative_to_root() -> None: with pytest.raises(ValueError): relative_interpreter("/tmp/relenv", "/tmp/bar/bin", "/tmp/relenv/bin/python3") -def test_sanitize_sys_path(): +def test_sanitize_sys_path() -> None: if sys.platform.startswith("win"): path_prefix = "C:\\" separator = "\\" @@ -237,3 +326,95 @@ def test_sanitize_sys_path(): new_sys_path = sanitize_sys_path(sys_path) assert new_sys_path != sys_path assert new_sys_path == expected + + +def test_version_parse_and_str() -> None: + version = Version("3.10.4") + assert version.major == 3 + assert version.minor == 10 + assert version.micro == 4 + assert str(version) == "3.10.4" + + +def test_version_equality_and_hash_handles_missing_parts() -> None: + left = Version("3.10") + right = Version("3.10.0") + assert left == right + assert isinstance(hash(left), int) + assert isinstance(hash(right), int) + + +def test_version_comparisons() -> None: + assert Version("3.9") < Version("3.10") + assert Version("3.10.1") > Version("3.10.0") + assert Version("3.11") >= Version("3.11.0") + assert Version("3.12.2") <= Version("3.12.2") + + +def test_version_parse_string_too_many_parts() -> None: + with pytest.raises(RuntimeError): + Version.parse_string("1.2.3.4") + + +def test_work_dirs_pickle_roundtrip(tmp_path: pathlib.Path) -> None: + data_dir = tmp_path / "data" + with patch("relenv.common.DATA_DIR", data_dir): + dirs = work_dirs(tmp_path) + restored = pickle.loads(pickle.dumps(dirs)) + assert restored.root == dirs.root + assert restored.toolchain == dirs.toolchain + assert restored.download == dirs.download + + +def test_work_dirs_with_data_dir_root(tmp_path: pathlib.Path) -> None: + data_dir = tmp_path / "data" + with patch("relenv.common.DATA_DIR", data_dir): + dirs = work_dirs(data_dir) + assert dirs.build == data_dir / "build" + assert dirs.logs == data_dir / "logs" + + +def test_list_archived_builds(tmp_path: pathlib.Path) -> None: + data_dir = tmp_path / "data" + build_dir = data_dir / "build" + build_dir.mkdir(parents=True) + archive = build_dir / "3.10.0-x86_64-linux-gnu.tar.xz" + archive.write_bytes(b"") + with patch("relenv.common.DATA_DIR", data_dir): + builds = list_archived_builds() + assert ("3.10.0", "x86_64", "linux-gnu") in builds + + +def test_addpackage_reads_paths(tmp_path: pathlib.Path) -> None: + sitedir = tmp_path + module_dir = tmp_path / "package" + module_dir.mkdir() + pth_file = sitedir / "example.pth" + pth_file.write_text(f"{module_dir.name}\n") + result = addpackage(str(sitedir), pth_file.name) + assert result == [str(module_dir.resolve())] + + +def test_sanitize_sys_path_with_editable_paths(tmp_path: pathlib.Path) -> None: + base = tmp_path / "base" + base.mkdir() + known_path = base / "lib" + known_path.mkdir() + editable_file = known_path / "__editable__.demo.pth" + editable_file.touch() + extra_path = str(known_path / "extra") + with patch.object(sys, "prefix", str(base)), patch.object( + sys, "base_prefix", str(base) + ), patch.dict(os.environ, {}, clear=True), patch( + "relenv.common.addpackage", return_value=[extra_path] + ): + sanitized = sanitize_sys_path([str(known_path)]) + assert extra_path in sanitized + + +def test_makepath_oserror() -> None: + with patch("relenv.common.os.path.abspath", side_effect=OSError): + result, case = makepath("foo", "Bar") + expected = os.path.join("foo", "Bar") + assert result == expected + assert case == os.path.normcase(expected) diff --git a/tests/test_module_imports.py b/tests/test_module_imports.py new file mode 100644 index 00000000..f7307609 --- /dev/null +++ b/tests/test_module_imports.py @@ -0,0 +1,33 @@ +# Copyright 2025 Broadcom. +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import importlib +import pathlib +from typing import List + +import pytest + + +def _top_level_modules() -> List[pytest.ParameterSet]: + relenv_dir = pathlib.Path(__file__).resolve().parents[1] / "relenv" + params: List[pytest.ParameterSet] = [] + for path in sorted(relenv_dir.iterdir()): + if not path.is_file() or path.suffix != ".py": + continue + stem = path.stem + if stem == "__init__": + module_name = "relenv" + else: + module_name = f"relenv.{stem}" + params.append(pytest.param(module_name, id=module_name)) + return params + + +@pytest.mark.parametrize("module_name", _top_level_modules()) +def test_import_top_level_module(module_name: str) -> None: + """ + Ensure each top-level module in the relenv package can be imported. + """ + importlib.import_module(module_name) diff --git a/tests/test_pyversions_runtime.py b/tests/test_pyversions_runtime.py new file mode 100644 index 00000000..346b060e --- /dev/null +++ b/tests/test_pyversions_runtime.py @@ -0,0 +1,95 @@ +# Copyright 2025 Broadcom. +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import hashlib +import pathlib +import subprocess +from typing import Dict + +import pytest + +from relenv import pyversions + + +def test_python_versions_returns_versions() -> None: + versions: Dict[pyversions.Version, str] = pyversions.python_versions() + assert versions, "python_versions() should return known versions" + first_version = next(iter(versions)) + assert isinstance(first_version, pyversions.Version) + assert isinstance(versions[first_version], str) + + +def test_python_versions_filters_minor() -> None: + versions = pyversions.python_versions("3.11") + assert versions + assert all(version.major == 3 and version.minor == 11 for version in versions) + sorted_versions = sorted(versions) + assert sorted_versions[-1] in versions + + +def test_release_urls_handles_old_versions() -> None: + tarball, signature = pyversions._release_urls(pyversions.Version("3.1.3")) + assert tarball.endswith(".tar.xz") + assert signature is not None + + +def test_release_urls_no_signature_before_23() -> None: + tarball, signature = pyversions._release_urls(pyversions.Version("2.2.3")) + assert tarball.endswith(".tar.xz") + assert signature is None + + +def test_ref_version_and_path_helpers() -> None: + html = 'Python 3.11.9' + version = pyversions._ref_version(html) + assert str(version) == "3.11.9" + assert pyversions._ref_path(html) == "download/Python-3.11.9.tgz" + + +def test_digest(tmp_path: pathlib.Path) -> None: + file = tmp_path / "data.bin" + file.write_bytes(b"abc") + assert pyversions.digest(file) == hashlib.sha1(b"abc").hexdigest() + + +def test_get_keyid_parses_second_line() -> None: + proc = subprocess.CompletedProcess( + ["gpg"], + 1, + stdout=b"", + stderr=b"gpg: error\n[GNUPG:] INV_SGNR 0 CB1234\n", + ) + assert pyversions._get_keyid(proc) == "CB1234" + + +def test_verify_signature_success( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + called: Dict[str, list[str]] = {} + + def fake_run(cmd, **kwargs): + called.setdefault("cmd", []).extend(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout=b"", stderr=b"") + + monkeypatch.setattr(pyversions.subprocess, "run", fake_run) + assert pyversions.verify_signature("archive.tgz", "archive.tgz.asc") is True + assert called["cmd"][0] == "gpg" + + +def test_verify_signature_failure_with_missing_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + responses: list[str] = [] + + def fake_run(cmd, **kwargs): + if len(responses) == 0: + responses.append("first") + stderr = b"gpg: error\n[GNUPG:] INV_SGNR 0 ABCDEF12\nNo public key\n" + return subprocess.CompletedProcess(cmd, 1, stdout=b"", stderr=stderr) + return subprocess.CompletedProcess(cmd, 0, stdout=b"", stderr=b"") + + monkeypatch.setattr(pyversions.subprocess, "run", fake_run) + monkeypatch.setattr(pyversions, "_receive_key", lambda keyid, server: True) + assert pyversions.verify_signature("archive.tgz", "archive.tgz.asc") is True diff --git a/tests/test_relocate_module.py b/tests/test_relocate_module.py new file mode 100644 index 00000000..ebda8104 --- /dev/null +++ b/tests/test_relocate_module.py @@ -0,0 +1,257 @@ +# Copyright 2025 Broadcom. +# SPDX-License-Identifier: Apache-2.0 +# +from __future__ import annotations + +import os +import pathlib +import shutil +import subprocess +from typing import Dict, List + +import pytest + +from relenv import relocate + + +def test_is_elf_on_text_file(tmp_path: pathlib.Path) -> None: + sample = tmp_path / "sample.txt" + sample.write_text("not an ELF binary\n") + assert relocate.is_elf(sample) is False + + +def test_is_macho_on_text_file(tmp_path: pathlib.Path) -> None: + sample = tmp_path / "sample.txt" + sample.write_text("plain text\n") + assert relocate.is_macho(sample) is False + + +def test_parse_readelf_output() -> None: + output = """ + 0x000000000000000f (NEEDED) Shared library: [libc.so.6] + 0x000000000000001d (RUNPATH) Library runpath: [/usr/lib:/opt/lib] +""" + result = relocate.parse_readelf_d(output) + assert result == ["/usr/lib", "/opt/lib"] + + +def test_parse_otool_output_extracts_rpaths() -> None: + sample_output = """ +Load command 0 + cmd LC_LOAD_DYLIB + cmdsize 56 + name /usr/lib/libSystem.B.dylib (offset 24) +Load command 1 + cmd LC_RPATH + cmdsize 32 + path @loader_path/../lib (offset 12) +""" + parsed = relocate.parse_otool_l(sample_output) + assert parsed[relocate.LC_LOAD_DYLIB] == ["/usr/lib/libSystem.B.dylib"] + assert parsed[relocate.LC_RPATH] == ["@loader_path/../lib"] + + +def test_patch_rpath_adds_new_entry( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + binary = tmp_path / "prog" + binary.write_text("dummy") + + monkeypatch.setattr( + relocate, + "parse_rpath", + lambda path: ["$ORIGIN/lib", "/abs/lib"], + ) + + recorded: Dict[str, List[str]] = {} + + def fake_run( + cmd: List[str], **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + recorded.setdefault("cmd", []).extend(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout=b"", stderr=b"") + + monkeypatch.setattr(relocate.subprocess, "run", fake_run) + + result = relocate.patch_rpath(binary, "$ORIGIN/../lib") + assert result == "$ORIGIN/../lib:$ORIGIN/lib" + assert pathlib.Path(recorded["cmd"][-1]) == binary + + +def test_patch_rpath_skips_when_present( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + binary = tmp_path / "prog" + binary.write_text("dummy") + + monkeypatch.setattr(relocate, "parse_rpath", lambda path: ["$ORIGIN/lib"]) + + def fail_run(*_args: object, **_kwargs: object) -> None: + raise AssertionError("patchelf should not be invoked") + + monkeypatch.setattr(relocate.subprocess, "run", fail_run) + + result = relocate.patch_rpath(binary, "$ORIGIN/lib") + assert result == "$ORIGIN/lib" + + +def test_handle_elf_sets_rpath( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + bin_dir = tmp_path / "bin" + lib_dir = tmp_path / "lib" + bin_dir.mkdir() + lib_dir.mkdir() + + binary = bin_dir / "prog" + binary.write_text("binary") + resident = lib_dir / "libfoo.so" + resident.write_text("library") + + def fake_run( + cmd: List[str], **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + if cmd[0] == "ldd": + stdout = f"libfoo.so => {resident} (0x00007)\nlibc.so.6 => /lib/libc.so.6 (0x00007)\n" + return subprocess.CompletedProcess( + cmd, 0, stdout=stdout.encode(), stderr=b"" + ) + raise AssertionError(f"Unexpected command {cmd}") + + monkeypatch.setattr(relocate.subprocess, "run", fake_run) + + captured: Dict[str, str] = {} + + def fake_patch_rpath(path: str, relpath: str) -> str: + captured["path"] = path + captured["relpath"] = relpath + return relpath + + monkeypatch.setattr(relocate, "patch_rpath", fake_patch_rpath) + + relocate.handle_elf(binary, lib_dir, rpath_only=False, root=lib_dir) + + assert pathlib.Path(captured["path"]) == binary + expected_rel = os.path.relpath(lib_dir, bin_dir) + if expected_rel == ".": + expected_rpath = "$ORIGIN" + else: + expected_rpath = str(pathlib.Path("$ORIGIN") / expected_rel) + assert captured["relpath"] == expected_rpath + + +def test_patch_rpath_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + binary = tmp_path / "prog" + binary.write_text("dummy") + + monkeypatch.setattr(relocate, "parse_rpath", lambda path: []) + + def fake_run( + cmd: List[str], **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + return subprocess.CompletedProcess(cmd, 1, stdout=b"", stderr=b"err") + + monkeypatch.setattr(relocate.subprocess, "run", fake_run) + + assert relocate.patch_rpath(binary, "$ORIGIN/lib") is False + + +def test_parse_macho_non_object( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + output = "foo: is not an object file\n" + monkeypatch.setattr( + relocate.subprocess, + "run", + lambda cmd, **kwargs: subprocess.CompletedProcess( + cmd, 0, stdout=output.encode(), stderr=b"" + ), + ) + assert relocate.parse_macho(tmp_path / "lib.dylib") is None + + +def test_handle_macho_copies_when_needed( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + binary = tmp_path / "bin" / "prog" + binary.parent.mkdir() + binary.write_text("exe") + source_lib = tmp_path / "src" / "libfoo.dylib" + source_lib.parent.mkdir() + source_lib.write_text("binary") + root_dir = tmp_path / "root" + root_dir.mkdir() + + monkeypatch.setattr( + relocate, + "parse_macho", + lambda path: {relocate.LC_LOAD_DYLIB: [str(source_lib)]}, + ) + + monkeypatch.setattr(os.path, "exists", lambda path: path == str(source_lib)) + + copied = {} + + monkeypatch.setattr( + shutil, "copy", lambda src, dst: copied.setdefault("copy", (src, dst)) + ) + monkeypatch.setattr( + shutil, "copymode", lambda src, dst: copied.setdefault("copymode", (src, dst)) + ) + + recorded: Dict[str, List[str]] = {} + + def fake_run( + cmd: List[str], **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + recorded.setdefault("cmd", []).extend(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout=b"", stderr=b"") + + monkeypatch.setattr(relocate.subprocess, "run", fake_run) + + relocate.handle_macho(str(binary), str(root_dir), rpath_only=False) + + assert copied["copy"][0] == str(source_lib) + assert pathlib.Path(copied["copy"][1]).name == source_lib.name + assert recorded["cmd"][0] == "install_name_tool" + + +def test_handle_macho_rpath_only( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + binary = tmp_path / "bin" / "prog" + binary.parent.mkdir() + binary.write_text("exe") + source_lib = tmp_path / "src" / "libfoo.dylib" + source_lib.parent.mkdir() + source_lib.write_text("binary") + root_dir = tmp_path / "root" + root_dir.mkdir() + + monkeypatch.setattr( + relocate, + "parse_macho", + lambda path: {relocate.LC_LOAD_DYLIB: [str(source_lib)]}, + ) + + monkeypatch.setattr( + os.path, + "exists", + lambda path: path == str(source_lib), + ) + + monkeypatch.setattr(shutil, "copy", lambda *_args, **_kw: (_args, _kw)) + monkeypatch.setattr(shutil, "copymode", lambda *_args, **_kw: (_args, _kw)) + + def fake_run( + cmd: List[str], **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + if cmd[0] == "install_name_tool": + raise AssertionError("install_name_tool should not run in rpath_only mode") + return subprocess.CompletedProcess(cmd, 0, stdout=b"", stderr=b"") + + monkeypatch.setattr(relocate.subprocess, "run", fake_run) + + relocate.handle_macho(str(binary), str(root_dir), rpath_only=True) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 878f2e06..c3d8c19b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,27 +1,1821 @@ # Copyright 2023-2025 Broadcom. # SPDX-License-Identifier: Apache-2.0 # +from __future__ import annotations + import importlib +import json +import os +import pathlib import sys +from types import ModuleType, SimpleNamespace +from typing import Optional + +import pytest import relenv.runtime -def test_importer(): - def mywrapper(name): +def _raise(exc: Exception): + raise exc + + +def test_path_import_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + monkeypatch.setattr( + importlib.util, "spec_from_file_location", lambda *args, **kwargs: None + ) + with pytest.raises(ImportError): + relenv.runtime.path_import("demo", tmp_path / "demo.py") + + +def test_path_import_success(tmp_path: pathlib.Path) -> None: + module_file = tmp_path / "mod.py" + module_file.write_text("value = 42\n") + mod = relenv.runtime.path_import("temp_mod", module_file) + assert mod.value == 42 + assert sys.modules["temp_mod"] is mod + + +def test_debug_print( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("RELENV_DEBUG", "1") + relenv.runtime.debug("hello") + out = capsys.readouterr().out + assert "hello" in out + monkeypatch.delenv("RELENV_DEBUG", raising=False) + + +def test_pushd_changes_directory(tmp_path: pathlib.Path) -> None: + original = os.getcwd() + with relenv.runtime.pushd(tmp_path): + assert os.getcwd() == str(tmp_path) + assert os.getcwd() == original + + +def test_relenv_root_windows(monkeypatch: pytest.MonkeyPatch) -> None: + module_dir = pathlib.Path(relenv.runtime.__file__).resolve().parent + fake_sys = SimpleNamespace(platform="win32") + monkeypatch.setattr(relenv.runtime, "sys", fake_sys) + expected = module_dir.parent.parent.parent + assert relenv.runtime.relenv_root() == expected + + +def test_get_major_version() -> None: + result = relenv.runtime.get_major_version() + major, minor = result.split(".") + assert major.isdigit() and minor.isdigit() + + +def test_importer() -> None: + def mywrapper(name: str) -> ModuleType: mod = importlib.import_module(name) - mod.__test_case__ = True + mod.__test_case__ = True # type: ignore[attr-defined] return mod importer = relenv.runtime.RelenvImporter( wrappers=[ - relenv.runtime.Wrapper("pip._internal.locations", mywrapper), + relenv.runtime.Wrapper( + "pip._internal.locations", + mywrapper, + ), ] ) sys.meta_path = [importer] + sys.meta_path - import pip._internal.locations + import pip._internal.locations # type: ignore[import] assert hasattr(pip._internal.locations, "__test_case__") assert pip._internal.locations.__test_case__ is True + + +def test_set_env_if_not_set( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + env_name = "RELENV_TEST_ENV" + monkeypatch.delenv(env_name, raising=False) + relenv.runtime.set_env_if_not_set(env_name, "value") + assert os.environ[env_name] == "value" + + monkeypatch.setenv(env_name, "other") + relenv.runtime.set_env_if_not_set(env_name, "value") + captured = capsys.readouterr() + assert "Warning:" in captured.out + + +def test_get_config_var_wrapper_with_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: pathlib.Path("/root")) + monkeypatch.setenv("RELENV_PIP_DIR", "1") + wrapped = relenv.runtime.get_config_var_wrapper(lambda name: "/orig") + assert wrapped("BINDIR") == pathlib.Path("/root") + monkeypatch.delenv("RELENV_PIP_DIR", raising=False) + + +def test_system_sysconfig_uses_system_python(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime, "_SYSTEM_CONFIG_VARS", None, raising=False) + + original_exists = pathlib.Path.exists + + def fake_exists(path: pathlib.Path) -> bool: + return str(path) == "/usr/bin/python3" + + monkeypatch.setattr(pathlib.Path, "exists", fake_exists) + expected = {"AR": "ar"} + completed = SimpleNamespace(stdout=json.dumps(expected).encode(), returncode=0) + monkeypatch.setattr( + relenv.runtime.subprocess, "run", lambda *args, **kwargs: completed + ) + + result = relenv.runtime.system_sysconfig() + assert result["AR"] == "ar" + + monkeypatch.setattr(pathlib.Path, "exists", original_exists) + + +def test_system_sysconfig_cached(monkeypatch: pytest.MonkeyPatch) -> None: + cache = {"AR": "cached"} + monkeypatch.setattr(relenv.runtime, "_SYSTEM_CONFIG_VARS", cache, raising=False) + result = relenv.runtime.system_sysconfig() + assert result is cache + monkeypatch.setattr(relenv.runtime, "_SYSTEM_CONFIG_VARS", None, raising=False) + + +def test_system_sysconfig_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime, "_SYSTEM_CONFIG_VARS", None, raising=False) + monkeypatch.setattr(pathlib.Path, "exists", lambda _path: False) + result = relenv.runtime.system_sysconfig() + assert result == relenv.runtime.CONFIG_VARS_DEFAULTS + + +def test_install_cargo_config_creates_file( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux") + data_dir = tmp_path / "data" + data_dir.mkdir() + toolchain_dir = tmp_path / "toolchain" / "x86_64-linux-gnu" + (toolchain_dir / "sysroot" / "lib").mkdir(parents=True) + (toolchain_dir / "bin").mkdir(parents=True) + (toolchain_dir / "bin" / "x86_64-linux-gnu-gcc").touch() + + class StubDirs: + def __init__(self, data: pathlib.Path) -> None: + self.data = data + + stub_dirs = StubDirs(data_dir) + stub_common = SimpleNamespace( + DATA_DIR=tmp_path, + work_dirs=lambda: stub_dirs, + get_triplet=lambda: "x86_64-linux-gnu", + get_toolchain=lambda: toolchain_dir, + ) + + monkeypatch.setattr(relenv.runtime, "common", lambda: stub_common) + relenv.runtime.install_cargo_config() + config_path = data_dir / "cargo" / "config.toml" + assert config_path.exists() + assert "x86_64-unknown-linux-gnu" in config_path.read_text() + + +def test_build_shebang_value_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr( + relenv.runtime, + "common", + lambda: SimpleNamespace( + relative_interpreter=lambda *args, **kwargs: _raise(ValueError("boom")) + ), + ) + + called = {"count": 0} + + def original(self: object, *args: object, **kwargs: object) -> bytes: # type: ignore[override] + called["count"] += 1 + return b"orig" + + wrapped = relenv.runtime._build_shebang(original) + result = wrapped(SimpleNamespace(target_dir="/tmp/dir")) + assert result == b"orig" + assert called["count"] == 1 + + +def test_build_shebang_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "win32", raising=False) + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr( + relenv.runtime, + "common", + lambda: SimpleNamespace( + relative_interpreter=lambda *args: pathlib.Path("python.exe") + ), + ) + + def original(self: object) -> bytes: # type: ignore[override] + return b"" + + wrapped = relenv.runtime._build_shebang(original) + result = wrapped(SimpleNamespace(target_dir="/tmp/dir")) + assert result.startswith(b"#!") and result.endswith(b"\r\n") + + +def test_get_config_var_wrapper_bindir(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: pathlib.Path("/root")) + wrapped = relenv.runtime.get_config_var_wrapper(lambda name: "/orig") + result = wrapped("BINDIR") + assert result == pathlib.Path("/root/Scripts") + + +def test_get_config_var_wrapper_other( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: pathlib.Path("/root")) + result = relenv.runtime.get_config_var_wrapper(lambda name: "value")("OTHER") + assert result == "value" + + +def test_system_sysconfig_json_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime, "_SYSTEM_CONFIG_VARS", None, raising=False) + monkeypatch.setattr( + pathlib.Path, "exists", lambda self: str(self) == "/usr/bin/python3" + ) + monkeypatch.setattr( + relenv.runtime.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(stdout=b"invalid", returncode=0), + ) + + def fake_loads(_data: bytes) -> dict: + raise json.JSONDecodeError("msg", "doc", 0) + + monkeypatch.setattr(relenv.runtime.json, "loads", fake_loads) + result = relenv.runtime.system_sysconfig() + assert result == relenv.runtime.CONFIG_VARS_DEFAULTS + + +def test_get_paths_wrapper_updates_scripts(monkeypatch: pytest.MonkeyPatch) -> None: + def original_get_paths( + *, scheme: Optional[str], vars: Optional[dict[str, str]], expand: bool + ) -> dict[str, str]: + return {"scripts": "/original/scripts"} + + wrapped = relenv.runtime.get_paths_wrapper(original_get_paths, "default") + monkeypatch.setenv("RELENV_PIP_DIR", "/tmp/scripts") + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: pathlib.Path("/relroot")) + + result = wrapped() + expected_root = os.fspath(pathlib.Path("/relroot")) + assert result["scripts"] == expected_root + assert relenv.runtime.sys.exec_prefix == expected_root + + monkeypatch.delenv("RELENV_PIP_DIR", raising=False) + + +def test_get_config_vars_wrapper_updates(monkeypatch: pytest.MonkeyPatch) -> None: + module = ModuleType("sysconfig") + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + + def original() -> dict[str, str]: + return { + key: "orig" + for key in ( + "AR", + "CC", + "CFLAGS", + "CPPFLAGS", + "CXX", + "LIBDEST", + "SCRIPTDIR", + "BLDSHARED", + "LDFLAGS", + "LDCXXSHARED", + "LDSHARED", + ) + } + + monkeypatch.setattr( + relenv.runtime, + "system_sysconfig", + lambda: { + key: "sys" + for key in ( + "AR", + "CC", + "CFLAGS", + "CPPFLAGS", + "CXX", + "LIBDEST", + "SCRIPTDIR", + "BLDSHARED", + "LDFLAGS", + "LDCXXSHARED", + "LDSHARED", + ) + }, + ) + wrapped = relenv.runtime.get_config_vars_wrapper(original, module) + result = wrapped() + assert module._CONFIG_VARS["AR"] == "sys" + assert result == { + key: "orig" + for key in ( + "AR", + "CC", + "CFLAGS", + "CPPFLAGS", + "CXX", + "LIBDEST", + "SCRIPTDIR", + "BLDSHARED", + "LDFLAGS", + "LDCXXSHARED", + "LDSHARED", + ) + } + + +def test_get_config_vars_wrapper_buildenv_skip(monkeypatch: pytest.MonkeyPatch) -> None: + module = ModuleType("sysconfig") + monkeypatch.setenv("RELENV_BUILDENV", "1") + marker = object() + wrapped = relenv.runtime.get_config_vars_wrapper(lambda: marker, module) + assert wrapped() is marker + monkeypatch.delenv("RELENV_BUILDENV", raising=False) + + +def test_finalize_options_wrapper_appends_include( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Dummy: + def __init__(self) -> None: + self.include_dirs: list[str] = [] + + def original(self: Dummy, *args: object, **kwargs: object) -> None: + self.include_dirs.append("existing") + + wrapped = relenv.runtime.finalize_options_wrapper(original) + dummy = Dummy() + monkeypatch.setenv("RELENV_BUILDENV", "1") + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: pathlib.Path("/relroot")) + wrapped(dummy) + expected_include = os.fspath(pathlib.Path("/relroot") / "include") + assert dummy.include_dirs == ["existing", expected_include] + monkeypatch.delenv("RELENV_BUILDENV", raising=False) + + +def test_install_wheel_wrapper_processes_record( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + plat_dir = tmp_path / "plat" + info_dir = plat_dir / "demo.dist-info" + info_dir.mkdir(parents=True) + record = info_dir / "RECORD" + record.write_text("libdemo.so,,\n") + binary = plat_dir / "libdemo.so" + binary.touch() + + handled: list[tuple[pathlib.Path, pathlib.Path]] = [] + monkeypatch.setattr( + relenv.runtime, + "relocate", + lambda: SimpleNamespace( + is_elf=lambda path: path.name.endswith(".so"), + is_macho=lambda path: False, + handle_elf=lambda file, lib_dir, fix, root: handled.append((file, lib_dir)), + handle_macho=lambda *args, **kwargs: None, + ), + ) + + wheel_utils = ModuleType("pip._internal.utils.wheel") + wheel_utils.parse_wheel = lambda _zf, _name: ("demo.dist-info", {}) + monkeypatch.setitem(sys.modules, wheel_utils.__name__, wheel_utils) + + class DummyZip: + def __init__(self, path: pathlib.Path) -> None: + self.path = path + + def __enter__(self) -> DummyZip: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + monkeypatch.setattr("zipfile.ZipFile", DummyZip) + + install_module = ModuleType("pip._internal.operations.install.wheel") + + def original_install(*_args: object, **_kwargs: object) -> str: + return "original" + + install_module.install_wheel = original_install # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, install_module.__name__, install_module) + + wrapped_module = relenv.runtime.wrap_pip_install_wheel(install_module.__name__) + + scheme = SimpleNamespace( + platlib=str(plat_dir), + ) + wrapped_module.install_wheel( + "demo", + tmp_path / "wheel.whl", + scheme, + "desc", + None, + None, + None, + None, + ) + + assert handled and handled[0][0].name == "libdemo.so" + + +def test_install_wheel_wrapper_missing_file( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + plat_dir = tmp_path / "plat" + info_dir = plat_dir / "demo.dist-info" + info_dir.mkdir(parents=True) + (info_dir / "RECORD").write_text("missing.so,,\n") + (info_dir / "WHEEL").write_text("Wheel-Version: 1.0\n") + import zipfile + + wheel_path = tmp_path / "demo_missing.whl" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr("demo.dist-info/RECORD", "missing.so,,\n") + zf.writestr("demo.dist-info/WHEEL", "Wheel-Version: 1.0\n") + + monkeypatch.setattr( + relenv.runtime, + "relocate", + lambda: SimpleNamespace(is_elf=lambda path: False, is_macho=lambda path: False), + ) + module_utils = ModuleType("pip._internal.utils.wheel.missing") + module_utils.parse_wheel = lambda zf, name: ("demo.dist-info", {}) + wheel_module = ModuleType("pip._internal.operations.install.wheel.missing") + wheel_module.install_wheel = lambda *args, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module_utils.__name__, module_utils) + monkeypatch.setitem(sys.modules, wheel_module.__name__, wheel_module) + monkeypatch.setattr( + relenv.runtime.importlib, + "import_module", + lambda name: wheel_module if name == wheel_module.__name__ else module_utils, + ) + scheme = SimpleNamespace(platlib=str(plat_dir)) + relenv.runtime.wrap_pip_install_wheel(wheel_module.__name__).install_wheel( + "demo", wheel_path, scheme, "desc", None, None, None, None + ) + + +def test_install_wheel_wrapper_macho_with_otool( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + plat_dir = tmp_path / "plat" + info_dir = plat_dir / "demo.dist-info" + info_dir.mkdir(parents=True) + (plat_dir / "libmach.dylib").touch() + (info_dir / "RECORD").write_text("libmach.dylib,,\n") + (info_dir / "WHEEL").write_text("Wheel-Version: 1.0\n") + import zipfile + + wheel_path = tmp_path / "demo_otool.whl" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr("demo.dist-info/RECORD", "libmach.dylib,,\n") + zf.writestr("demo.dist-info/WHEEL", "Wheel-Version: 1.0\n") + + monkeypatch.setattr( + relenv.runtime, + "relocate", + lambda: SimpleNamespace( + is_elf=lambda path: False, + is_macho=lambda path: True, + handle_macho=lambda *args, **kwargs: None, + ), + ) + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda cmd: "/usr/bin/otool") + module_utils = ModuleType("pip._internal.utils.wheel.otool") + module_utils.parse_wheel = lambda zf, name: ("demo.dist-info", {}) + wheel_module = ModuleType("pip._internal.operations.install.wheel.otool") + wheel_module.install_wheel = lambda *args, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module_utils.__name__, module_utils) + monkeypatch.setitem(sys.modules, wheel_module.__name__, wheel_module) + monkeypatch.setattr( + relenv.runtime.importlib, + "import_module", + lambda name: wheel_module if name == wheel_module.__name__ else module_utils, + ) + scheme = SimpleNamespace(platlib=str(plat_dir)) + relenv.runtime.wrap_pip_install_wheel(wheel_module.__name__).install_wheel( + "demo", wheel_path, scheme, "desc", None, None, None, None + ) + + +def test_install_wheel_wrapper_macho_without_otool( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + plat_dir = tmp_path / "plat" + info_dir = plat_dir / "demo.dist-info" + info_dir.mkdir(parents=True) + (plat_dir / "libmach.dylib").touch() + (info_dir / "RECORD").write_text("libmach.dylib,,\n") + (info_dir / "WHEEL").write_text("Wheel-Version: 1.0\n") + import zipfile + + wheel_path = tmp_path / "demo_no_otool.whl" + with zipfile.ZipFile(wheel_path, "w") as zf: + zf.writestr("demo.dist-info/RECORD", "libmach.dylib,,\n") + zf.writestr("demo.dist-info/WHEEL", "Wheel-Version: 1.0\n") + + monkeypatch.setattr( + relenv.runtime, + "relocate", + lambda: SimpleNamespace( + is_elf=lambda path: False, + is_macho=lambda path: True, + handle_macho=lambda *args, **kwargs: _raise( + AssertionError("unexpected macho") + ), + ), + ) + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda cmd: None) + messages: list[str] = [] + monkeypatch.setattr(relenv.runtime, "debug", lambda msg: messages.append(str(msg))) + module_utils = ModuleType("pip._internal.utils.wheel.no_otool") + module_utils.parse_wheel = lambda zf, name: ("demo.dist-info", {}) + wheel_module = ModuleType("pip._internal.operations.install.wheel.no_otool") + wheel_module.install_wheel = lambda *args, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module_utils.__name__, module_utils) + monkeypatch.setitem(sys.modules, wheel_module.__name__, wheel_module) + monkeypatch.setattr( + relenv.runtime.importlib, + "import_module", + lambda name: wheel_module if name == wheel_module.__name__ else module_utils, + ) + scheme = SimpleNamespace(platlib=str(plat_dir)) + relenv.runtime.wrap_pip_install_wheel(wheel_module.__name__).install_wheel( + "demo", wheel_path, scheme, "desc", None, None, None, None + ) + assert any("otool command is not available" in msg for msg in messages) + + +def test_install_legacy_wrapper_prefix( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + (pkg_dir / "PKG-INFO").write_text("Version: 1.0\nName: demo\n") + sitepack = ( + tmp_path + / "prefix" + / "lib" + / f"python{relenv.runtime.get_major_version()}" + / "site-packages" + ) + egg_dir = sitepack / "demo-1.0.egg-info" + egg_dir.mkdir(parents=True) + (egg_dir / "installed-files.txt").write_text("missing.so\n") + scheme = SimpleNamespace( + purelib=str(tmp_path / "pure"), platlib=str(tmp_path / "pure") + ) + module = ModuleType("pip._internal.operations.install.legacy.prefix") + module.install = lambda *args, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module.__name__, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + monkeypatch.setattr( + relenv.runtime, + "relocate", + lambda: SimpleNamespace(is_elf=lambda path: False, is_macho=lambda path: False), + ) + wrapper = relenv.runtime.wrap_pip_install_legacy(module.__name__) + wrapper.install( + None, + None, + str(sitepack.parent.parent.parent), + None, + str(sitepack.parent.parent.parent), + False, + False, + scheme, + str(pkg_dir / "setup.py"), + False, + "demo", + None, + pkg_dir, + "demo", + ) + + +def test_install_legacy_wrapper_no_egginfo( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + (pkg_dir / "PKG-INFO").write_text("Name: demo\nVersion: 1.0\n") + scheme = SimpleNamespace(purelib=str(tmp_path / "pure")) + module = ModuleType("pip._internal.operations.install.legacy.none") + module.install = lambda *args, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module.__name__, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + wrapper = relenv.runtime.wrap_pip_install_legacy(module.__name__) + wrapper.install( + None, + None, + None, + None, + None, + False, + False, + scheme, + str(pkg_dir / "setup.py"), + False, + "demo", + None, + pkg_dir, + "demo", + ) + + +def test_install_legacy_wrapper_file_missing( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + (pkg_dir / "PKG-INFO").write_text("Name: demo\nVersion: 1.0\n") + egg_dir = tmp_path / "pure" / "demo-1.0.egg-info" + egg_dir.mkdir(parents=True) + (egg_dir / "installed-files.txt").write_text("missing.so\n") + scheme = SimpleNamespace( + purelib=str(tmp_path / "pure"), platlib=str(tmp_path / "pure") + ) + module = ModuleType("pip._internal.operations.install.legacy.missing") + module.install = lambda *args, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module.__name__, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + monkeypatch.setattr( + relenv.runtime, + "relocate", + lambda: SimpleNamespace(is_elf=lambda path: False, is_macho=lambda path: False), + ) + wrapper = relenv.runtime.wrap_pip_install_legacy(module.__name__) + wrapper.install( + None, + None, + None, + None, + None, + False, + False, + scheme, + str(pkg_dir / "setup.py"), + False, + "demo", + None, + pkg_dir, + "demo", + ) + + +def test_install_legacy_wrapper_handles_elf( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + (pkg_dir / "PKG-INFO").write_text("Name: demo\nVersion: 1.0\n") + egg_dir = tmp_path / "pure" / "demo-1.0.egg-info" + egg_dir.mkdir(parents=True) + binary = tmp_path / "pure" / "libdemo.so" + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_bytes(b"") + (egg_dir / "installed-files.txt").write_text(f"{binary}\n") + scheme = SimpleNamespace( + purelib=str(tmp_path / "pure"), platlib=str(tmp_path / "pure") + ) + module = ModuleType("pip._internal.operations.install.legacy.elf") + module.install = lambda *args, **kwargs: None # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module.__name__, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + handled: list[tuple[pathlib.Path, pathlib.Path, bool, pathlib.Path]] = [] + + def fake_relocate() -> SimpleNamespace: + return SimpleNamespace( + is_elf=lambda path: path == binary, + is_macho=lambda path: False, + handle_elf=lambda *args: handled.append(args), + ) + + monkeypatch.setattr(relenv.runtime, "relocate", fake_relocate) + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: tmp_path) + wrapper = relenv.runtime.wrap_pip_install_legacy(module.__name__) + wrapper.install( + None, + None, + None, + None, + None, + False, + False, + scheme, + str(pkg_dir / "setup.py"), + False, + "demo", + None, + pkg_dir, + "demo", + ) + assert handled and handled[0][0] == binary + + +def test_wrap_sysconfig_legacy(monkeypatch: pytest.MonkeyPatch) -> None: + module = ModuleType("sysconfig") + + def get_config_var(name: str) -> str: + return name + + def get_config_vars() -> dict[str, str]: + return relenv.runtime.CONFIG_VARS_DEFAULTS.copy() + + def get_paths(**kwargs: object) -> dict[str, str]: + return {"scripts": "/tmp"} + + def default_scheme() -> str: + return "legacy" + + module.get_config_var = get_config_var + module.get_config_vars = get_config_vars + module.get_paths = get_paths + module._get_default_scheme = default_scheme # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "sysconfig.legacy", module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + wrapped = relenv.runtime.wrap_sysconfig("sysconfig.legacy") + assert wrapped is module + + +def test_wrap_pip_distlib_scripts(monkeypatch: pytest.MonkeyPatch) -> None: + class ScriptMaker: + def __init__(self) -> None: + self.target_dir = "/tmp/dir" + + def _build_shebang(self, target: str) -> bytes: + return b"orig" + + module = ModuleType("pip._vendor.distlib.scripts") + module.ScriptMaker = ScriptMaker + monkeypatch.setitem(sys.modules, module.__name__, module) + wrapped = relenv.runtime.wrap_pip_distlib_scripts(module.__name__) + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr( + relenv.runtime, + "common", + lambda: SimpleNamespace( + relative_interpreter=lambda *args, **kwargs: _raise(ValueError("boom")) + ), + ) + result = wrapped.ScriptMaker()._build_shebang("target") + assert result == b"orig" + + +def test_wrap_distutils_command(monkeypatch: pytest.MonkeyPatch) -> None: + class BuildExt: + def finalize_options(self) -> None: + return None + + module = ModuleType("distutils.command.build_ext") + module.build_ext = BuildExt + monkeypatch.setitem(sys.modules, module.__name__, module) + wrapped = relenv.runtime.wrap_distutils_command(module.__name__) + dummy = wrapped.build_ext() + monkeypatch.setenv("RELENV_BUILDENV", "1") + monkeypatch.setattr(relenv.runtime, "relenv_root", lambda: pathlib.Path("/rel")) + dummy.include_dirs = [] + wrapped.build_ext.finalize_options(dummy) + expected_include = os.fspath(pathlib.Path("/rel") / "include") + assert expected_include in dummy.include_dirs + monkeypatch.delenv("RELENV_BUILDENV", raising=False) + + +def test_wrap_pip_build_wheel_sets_env( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + relenv.runtime.TARGET.TARGET = False + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + toolchain = tmp_path / "toolchain" / "trip" + (toolchain / "sysroot" / "lib").mkdir(parents=True) + toolchain.mkdir(parents=True, exist_ok=True) + base_dir = tmp_path + set_calls: list[tuple[str, str]] = [] + monkeypatch.setattr( + relenv.runtime, + "set_env_if_not_set", + lambda name, value: set_calls.append((name, value)), + ) + stub_common = SimpleNamespace( + DATA_DIR=base_dir, + get_triplet=lambda: "trip", + get_toolchain=lambda: toolchain, + ) + monkeypatch.setattr(relenv.runtime, "common", lambda: stub_common) + + class DummyModule(ModuleType): + def build_wheel_pep517(self, *args: object, **kwargs: object) -> str: # type: ignore[override] + return "built" + + module_name = "pip._internal.operations.build.wheel" + dummy = DummyModule(module_name) + monkeypatch.setitem(sys.modules, module_name, dummy) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: dummy) + + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + wrapped = relenv.runtime.wrap_pip_build_wheel(module_name) + result = wrapped.build_wheel_pep517("backend", {}, {}) + assert result == "built" + assert any(name == "CARGO_HOME" for name, _ in set_calls) + + +def test_wrap_pip_build_wheel_toolchain_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + relenv.runtime.TARGET.TARGET = False + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + stub_common = SimpleNamespace( + DATA_DIR=pathlib.Path("/data"), + get_triplet=lambda: "trip", + get_toolchain=lambda: None, + ) + monkeypatch.setattr(relenv.runtime, "common", lambda: stub_common) + module_name = "pip._internal.operations.build.none" + module = ModuleType(module_name) + module.build_wheel_pep517 = lambda *args, **kwargs: "built" # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module_name, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + + wrapped = relenv.runtime.wrap_pip_build_wheel(module_name) + assert wrapped.build_wheel_pep517("backend", {}, {}) == "built" + + +def test_wrap_pip_build_wheel_non_linux(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "darwin", raising=False) + module_name = "pip._internal.operations.build.nonlinux" + module = ModuleType(module_name) + module.build_wheel_pep517 = lambda *args, **kwargs: "built" # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, module_name, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + wrapped = relenv.runtime.wrap_pip_build_wheel(module_name) + assert wrapped.build_wheel_pep517("backend", {}, {}) == "built" + + +def test_wrap_cmd_install_updates_target(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = False + relenv.runtime.TARGET.PATH = None + relenv.runtime.TARGET.IGNORE = False + + fake_module = ModuleType("pip._internal.commands.install") + + class FakeInstallCommand: + def run(self, options: SimpleNamespace, args: list[str]) -> str: + options.ran = True + return "ran" + + def _handle_target_dir( + self, target_dir: str, target_temp_dir: str, upgrade: bool + ) -> str: + return "handled" + + fake_module.InstallCommand = FakeInstallCommand + monkeypatch.setitem(sys.modules, fake_module.__name__, fake_module) + + status_module = ModuleType("pip._internal.cli.status_codes") + status_module.SUCCESS = 0 + monkeypatch.setitem(sys.modules, status_module.__name__, status_module) + + original_import = relenv.runtime.importlib.import_module + + def fake_import_module(name: str) -> ModuleType: + if name == fake_module.__name__: + return fake_module + return original_import(name) + + monkeypatch.setattr(relenv.runtime.importlib, "import_module", fake_import_module) + + wrapped = relenv.runtime.wrap_cmd_install(fake_module.__name__) + options = SimpleNamespace( + use_user_site=False, target_dir="/tmp/target", ignore_installed=True + ) + command = wrapped.InstallCommand() + result = command.run(options, []) + + assert result == "ran" + assert relenv.runtime.TARGET.TARGET is True + assert relenv.runtime.TARGET.PATH == "/tmp/target" + assert relenv.runtime.TARGET.IGNORE is True + assert command._handle_target_dir("a", "b", True) == 0 + + +def test_wrap_cmd_install_no_user_site(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = False + fake_module = ModuleType("pip._internal.commands.install.skip") + + class InstallCommand: + def run(self, options: SimpleNamespace, args: list[str]) -> str: + return "ran" + + fake_module.InstallCommand = InstallCommand + monkeypatch.setitem(sys.modules, fake_module.__name__, fake_module) + + module_status = ModuleType("pip._internal.cli.status_codes") + module_status.SUCCESS = 0 + monkeypatch.setitem(sys.modules, module_status.__name__, module_status) + + monkeypatch.setattr( + relenv.runtime.importlib, + "import_module", + lambda name: fake_module if name == fake_module.__name__ else module_status, + ) + + wrapped = relenv.runtime.wrap_cmd_install(fake_module.__name__) + options = SimpleNamespace( + use_user_site=True, target_dir=None, ignore_installed=False + ) + result = wrapped.InstallCommand().run(options, []) + assert result == "ran" + assert relenv.runtime.TARGET.TARGET is False + + +def test_wrap_locations_applies_target(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = True + relenv.runtime.TARGET.INSTALL = True + relenv.runtime.TARGET.PATH = "/target/path" + + scheme_module = ModuleType("pip._internal.models.scheme") + + class Scheme: + def __init__( + self, + platlib: str, + purelib: str, + headers: str, + scripts: str, + data: str, + ) -> None: + self.platlib = platlib + self.purelib = purelib + self.headers = headers + self.scripts = scripts + self.data = data + + scheme_module.Scheme = Scheme + monkeypatch.setitem(sys.modules, scheme_module.__name__, scheme_module) + + fake_locations = ModuleType("pip._internal.locations") + + class OriginalScheme: + platlib = "/original/plat" + purelib = "/original/pure" + headers = "headers" + scripts = "scripts" + data = "data" + + def get_scheme( + dist_name: str, + user: bool = False, + home: str | None = None, + root: str | None = None, + isolated: bool = False, + prefix: str | None = None, + ) -> OriginalScheme: + return OriginalScheme() + + fake_locations.get_scheme = get_scheme # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, fake_locations.__name__, fake_locations) + + original_import = relenv.runtime.importlib.import_module + + def fake_import_module(name: str) -> ModuleType: + if name == fake_locations.__name__: + return fake_locations + if name == scheme_module.__name__: + return scheme_module + return original_import(name) + + monkeypatch.setattr(relenv.runtime.importlib, "import_module", fake_import_module) + + wrapped = relenv.runtime.wrap_locations(fake_locations.__name__) + scheme = wrapped.get_scheme("dist") + assert scheme.platlib == "/target/path" + assert scheme.purelib == "/target/path" + assert scheme.headers == "headers" + assert scheme.scripts == "scripts" + assert scheme.data == "data" + + +def test_wrap_locations_without_target(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = False + fake_module = ModuleType("pip._internal.locations.plain") + + class OriginalScheme: + def __init__(self) -> None: + self.platlib = "/plat" + + fake_module.get_scheme = lambda *args, **kwargs: OriginalScheme() + monkeypatch.setitem(sys.modules, fake_module.__name__, fake_module) + monkeypatch.setattr( + relenv.runtime.importlib, "import_module", lambda name: fake_module + ) + + wrapped = relenv.runtime.wrap_locations(fake_module.__name__) + scheme = wrapped.get_scheme("dist") + assert scheme.platlib == "/plat" + + +def test_wrap_req_command_honors_ignore(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = True + relenv.runtime.TARGET.IGNORE = True + + fake_module = ModuleType("pip._internal.cli.req_command") + + class RequirementCommand: + def _build_package_finder( + self, + options: SimpleNamespace, + session: object, + target_python: object | None = None, + ignore_requires_python: object | None = None, + ) -> bool: + return options.ignore_installed + + fake_module.RequirementCommand = RequirementCommand + monkeypatch.setitem(sys.modules, fake_module.__name__, fake_module) + + original_import = relenv.runtime.importlib.import_module + + def fake_import_module(name: str) -> ModuleType: + if name == fake_module.__name__: + return fake_module + return original_import(name) + + monkeypatch.setattr(relenv.runtime.importlib, "import_module", fake_import_module) + + wrapped = relenv.runtime.wrap_req_command(fake_module.__name__) + command = wrapped.RequirementCommand() + options = SimpleNamespace(ignore_installed=False) + result = command._build_package_finder(options, object()) + assert options.ignore_installed is True + assert result is True + + +def test_wrap_req_command_without_target(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = False + fake_module = ModuleType("pip._internal.cli.req_command.plain") + + class RequirementCommand: + def _build_package_finder( + self, + options: SimpleNamespace, + session: object, + target_python: object | None = None, + ignore_requires_python: object | None = None, + ) -> bool: + return options.ignore_installed + + fake_module.RequirementCommand = RequirementCommand + monkeypatch.setitem(sys.modules, fake_module.__name__, fake_module) + monkeypatch.setattr( + relenv.runtime.importlib, "import_module", lambda name: fake_module + ) + + wrapped = relenv.runtime.wrap_req_command(fake_module.__name__) + options = SimpleNamespace(ignore_installed=False) + result = wrapped.RequirementCommand()._build_package_finder(options, object()) + assert result is False + + +def test_wrap_req_install_sets_target_home(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = True + relenv.runtime.TARGET.PATH = "/target/path" + + fake_module = ModuleType("pip._internal.req.req_install") + + class InstallRequirement: + def install( + self, + install_options: object, + global_options: object, + root: object, + home: object, + prefix: object, + warn_script_location: bool, + use_user_site: bool, + pycompile: bool, + ) -> tuple[object, object]: + return install_options, home + + fake_module.InstallRequirement = InstallRequirement + monkeypatch.setitem(sys.modules, fake_module.__name__, fake_module) + + original_import = relenv.runtime.importlib.import_module + + def fake_import_module(name: str) -> ModuleType: + if name == fake_module.__name__: + return fake_module + return original_import(name) + + monkeypatch.setattr(relenv.runtime.importlib, "import_module", fake_import_module) + + wrapped = relenv.runtime.wrap_req_install(fake_module.__name__) + installer = wrapped.InstallRequirement() + _, home = installer.install(None, None, None, None, None, True, False, True) + assert home == relenv.runtime.TARGET.PATH + + +def test_wrap_req_install_short_signature(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = True + relenv.runtime.TARGET.PATH = "/another/path" + + module_name = "pip._internal.req.req_install.short" + short_module = ModuleType(module_name) + + class InstallRequirement: + def install( + self, + global_options: object = None, + root: object = None, + home: object = None, + prefix: object = None, + warn_script_location: bool = True, + use_user_site: bool = False, + pycompile: bool = True, + ) -> tuple[object, object]: + return global_options, home + + short_module.InstallRequirement = InstallRequirement + monkeypatch.setitem(sys.modules, module_name, short_module) + + original_import = relenv.runtime.importlib.import_module + + def fake_import_module(name: str) -> ModuleType: + if name == module_name: + return short_module + return original_import(name) + + monkeypatch.setattr(relenv.runtime.importlib, "import_module", fake_import_module) + + wrapped = relenv.runtime.wrap_req_install(module_name) + installer = wrapped.InstallRequirement() + _, home = installer.install() + assert home == relenv.runtime.TARGET.PATH + + +def test_wrap_req_install_no_target(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = False + module_name = "pip._internal.req.req_install.none" + module = ModuleType(module_name) + + class InstallRequirement: + def install( + self, + install_options: object, + global_options: object, + root: object, + home: object, + prefix: object, + warn_script_location: bool, + use_user_site: bool, + pycompile: bool, + ) -> str: + return "installed" + + module.InstallRequirement = InstallRequirement + monkeypatch.setitem(sys.modules, module_name, module) + monkeypatch.setattr(relenv.runtime.importlib, "import_module", lambda name: module) + + wrapped = relenv.runtime.wrap_req_install(module_name) + installer = wrapped.InstallRequirement() + result = installer.install(None, None, None, None, None, True, False, True) + assert result == "installed" + + +def test_wrapsitecustomize_sanitizes_sys_path(monkeypatch: pytest.MonkeyPatch) -> None: + sanitized = ["sanitized/path"] + monkeypatch.setattr( + relenv.runtime, + "common", + lambda: SimpleNamespace(sanitize_sys_path=lambda _: sanitized), + ) + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + + def original() -> None: + pass + + wrapped = relenv.runtime.wrapsitecustomize(original) + wrapped() + assert relenv.runtime.site.ENABLE_USER_SITE is False + assert relenv.runtime.sys.path == sanitized + + +def test_install_cargo_config_toolchain_none(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + stub_common = SimpleNamespace( + DATA_DIR=pathlib.Path("/data"), + work_dirs=lambda: SimpleNamespace(data=pathlib.Path("/data")), + get_triplet=lambda: "trip", + get_toolchain=lambda: None, + ) + monkeypatch.setattr(relenv.runtime, "common", lambda: stub_common) + relenv.runtime.install_cargo_config() + + +def test_install_cargo_config_toolchain_missing_dir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + toolchain = SimpleNamespace(exists=lambda: False) + stub_common = SimpleNamespace( + DATA_DIR=pathlib.Path("/data"), + work_dirs=lambda: SimpleNamespace(data=pathlib.Path("/data")), + get_triplet=lambda: "trip", + get_toolchain=lambda: toolchain, + ) + monkeypatch.setattr(relenv.runtime, "common", lambda: stub_common) + relenv.runtime.install_cargo_config() + + +def test_install_cargo_config_non_linux(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "darwin", raising=False) + relenv.runtime.install_cargo_config() + + +def test_install_cargo_config_alt_triplet( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + data_dir = tmp_path / "data" + data_dir.mkdir() + toolchain_dir = tmp_path / "toolchain" / "aarch" + (toolchain_dir / "sysroot" / "lib").mkdir(parents=True) + (toolchain_dir / "bin").mkdir(parents=True) + (toolchain_dir / "bin" / "aarch-gcc").touch() + stub_common = SimpleNamespace( + DATA_DIR=tmp_path, + work_dirs=lambda: SimpleNamespace(data=data_dir), + get_triplet=lambda: "aarch", + get_toolchain=lambda: toolchain_dir, + ) + monkeypatch.setattr(relenv.runtime, "common", lambda: stub_common) + relenv.runtime.install_cargo_config() + assert (data_dir / "cargo" / "config.toml").exists() + + +def test_setup_openssl_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "win32", raising=False) + relenv.runtime.setup_openssl() + + +def test_setup_openssl_without_binary( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(relenv.runtime.sys, "RELENV", tmp_path, raising=False) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux") + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda _: None) + + modules_dirs: list[str] = [] + monkeypatch.setattr(relenv.runtime, "set_openssl_modules_dir", modules_dirs.append) + providers: list[str] = [] + + def fail_provider(name: str) -> int: + providers.append(name) + return 0 + + monkeypatch.setattr(relenv.runtime, "load_openssl_provider", fail_provider) + + monkeypatch.delenv("OPENSSL_MODULES", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + relenv.runtime.setup_openssl() + assert modules_dirs[-1].endswith("ossl-modules") + assert providers == ["default", "legacy"] + + +def test_setup_openssl_with_system_binary( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(relenv.runtime.sys, "RELENV", tmp_path, raising=False) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux") + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda _: "/usr/bin/openssl") + + module_calls: list[str] = [] + monkeypatch.setattr(relenv.runtime, "set_openssl_modules_dir", module_calls.append) + + providers: list[str] = [] + monkeypatch.setattr( + relenv.runtime, + "load_openssl_provider", + lambda name: providers.append(name) or 1, + ) + + def fake_run(args: list[str], **kwargs: object) -> SimpleNamespace: + if args[:2] == ["/usr/bin/openssl", "version"]: + if "-m" in args: + return SimpleNamespace( + returncode=0, stdout='MODULESDIR: "/usr/lib/ssl"' + ) + if "-d" in args: + return SimpleNamespace(returncode=0, stdout='OPENSSLDIR: "/etc/ssl"') + return SimpleNamespace(returncode=1, stdout="", stderr="error") + + monkeypatch.setattr(relenv.runtime.subprocess, "run", fake_run) + + certs_dir = pathlib.Path("/etc/ssl/certs") + monkeypatch.setattr( + pathlib.Path, + "exists", + lambda self: str(self) + in (str(certs_dir), str(tmp_path / "lib" / "libcrypto.so")), + ) + + monkeypatch.delenv("OPENSSL_MODULES", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + relenv.runtime.setup_openssl() + + assert module_calls[0] == "/usr/lib/ssl" + assert module_calls[-1].endswith("ossl-modules") + assert {"default", "legacy"} <= set(providers) + assert os.environ["SSL_CERT_DIR"] == str(certs_dir) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + +def test_relenv_importer_loading(monkeypatch: pytest.MonkeyPatch) -> None: + loaded: list[str] = [] + + def wrapper(name: str) -> ModuleType: + mod = ModuleType(name) + mod.loaded = True # type: ignore[attr-defined] + loaded.append(name) + return mod + + wrapper_obj = relenv.runtime.Wrapper("pkg.sub", wrapper, matcher="startswith") + importer = relenv.runtime.RelenvImporter([wrapper_obj]) + assert importer.find_module("pkg.sub.module") is importer + wrapper_obj.loading = False + spec = importer.find_spec("pkg.sub.module") + assert spec is not None + module = importer.load_module("pkg.sub.module") + assert getattr(module, "loaded", False) + assert loaded == ["pkg.sub.module"] + importer.create_module(spec) + importer.exec_module(module) + + +def test_relenv_importer_defaults() -> None: + importer = relenv.runtime.RelenvImporter() + assert importer.wrappers == set() + assert importer._loads == {} + + +def test_install_cargo_config_toolchain_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + stub_common = SimpleNamespace( + DATA_DIR=pathlib.Path("/data"), + work_dirs=lambda: SimpleNamespace(data=pathlib.Path("/data")), + get_triplet=lambda: "trip", + get_toolchain=lambda: None, + ) + monkeypatch.setattr(relenv.runtime, "common", lambda: stub_common) + relenv.runtime.install_cargo_config() + + +def test_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + monkeypatch.setattr( + relenv.runtime, "relenv_root", lambda: pathlib.Path("/relbootstrap") + ) + monkeypatch.setattr(relenv.runtime, "setup_openssl", lambda: calls.append("ssl")) + monkeypatch.setattr( + relenv.runtime.site, "execsitecustomize", lambda: None, raising=False + ) + monkeypatch.setattr( + relenv.runtime, "setup_crossroot", lambda: calls.append("cross") + ) + monkeypatch.setattr( + relenv.runtime, "install_cargo_config", lambda: calls.append("cargo") + ) + monkeypatch.setattr( + relenv.runtime.warnings, "filterwarnings", lambda *args, **kwargs: None + ) + original_meta = list(relenv.runtime.sys.meta_path) + relenv.runtime.bootstrap() + assert relenv.runtime.sys.RELENV == pathlib.Path("/relbootstrap") + assert calls == ["ssl", "cross", "cargo"] + assert relenv.runtime.sys.meta_path[0] is relenv.runtime.importer + relenv.runtime.sys.meta_path = original_meta + + +def test_common_path_import_invoked(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delattr(relenv.runtime.common, "common", raising=False) + sentinel = ModuleType("cached.common") + monkeypatch.setattr(relenv.runtime, "path_import", lambda name, path: sentinel) + result = relenv.runtime.common() + assert result is sentinel + + +def test_relocate_path_import_invoked(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delattr(relenv.runtime.relocate, "relocate", raising=False) + sentinel = ModuleType("cached.relocate") + monkeypatch.setattr(relenv.runtime, "path_import", lambda name, path: sentinel) + result = relenv.runtime.relocate() + assert result is sentinel + + +def test_buildenv_path_import_invoked(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delattr(relenv.runtime.buildenv, "builenv", raising=False) + monkeypatch.delattr(relenv.runtime.buildenv, "buildenv", raising=False) + sentinel = ModuleType("cached.buildenv") + monkeypatch.setattr(relenv.runtime, "path_import", lambda name, path: sentinel) + result = relenv.runtime.buildenv() + assert result is sentinel + + +def test_common_cached(monkeypatch: pytest.MonkeyPatch) -> None: + count = {"calls": 0} + + def loader(name: str, path: str) -> ModuleType: + count["calls"] += 1 + return ModuleType(name) + + monkeypatch.setattr(relenv.runtime, "path_import", loader) + module1 = relenv.runtime.common() + module2 = relenv.runtime.common() + assert module1 is module2 + assert count["calls"] == 0 + + +def test_relocate_cached(monkeypatch: pytest.MonkeyPatch) -> None: + module = ModuleType("relenv.relocate.cached") + monkeypatch.setattr(relenv.runtime.relocate, "relocate", module, raising=False) + result = relenv.runtime.relocate() + assert result is module + + +def test_buildenv_cached(monkeypatch: pytest.MonkeyPatch) -> None: + module = ModuleType("relenv.buildenv.cached") + monkeypatch.setattr(relenv.runtime.buildenv, "builenv", True, raising=False) + monkeypatch.setattr(relenv.runtime.buildenv, "buildenv", module, raising=False) + result = relenv.runtime.buildenv() + assert result is module + + +def test_build_shebang_target(monkeypatch: pytest.MonkeyPatch) -> None: + relenv.runtime.TARGET.TARGET = True + relenv.runtime.TARGET.PATH = "/target" + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr( + relenv.runtime, + "common", + lambda: SimpleNamespace( + relative_interpreter=lambda *args: pathlib.Path("bin/python"), + format_shebang=lambda path: f"#!{path}", + ), + ) + + def original(self: object) -> bytes: # type: ignore[override] + return b"" + + result = relenv.runtime._build_shebang(original)( + SimpleNamespace(target_dir="/tmp/scripts") + ) + shebang = result.decode().strip() + assert shebang.startswith("#!") + path_part = shebang[2:] + expected_suffix = os.fspath(pathlib.Path("bin") / "python") + normalized = path_part.replace("\\", "/") + assert normalized.endswith(expected_suffix.replace("\\", "/")) + relenv.runtime.TARGET.TARGET = False + relenv.runtime.TARGET.PATH = None + + +def test_build_shebang_linux(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + + class StubCommon: + @staticmethod + def relative_interpreter(_relenv, _scripts, _exec): + return pathlib.Path("bin/python") + + @staticmethod + def format_shebang(path: pathlib.Path) -> str: + return f"#!{path}" + + monkeypatch.setattr(relenv.runtime, "common", lambda: StubCommon()) + + def original(self: object) -> bytes: # type: ignore[override] + return b"" + + result = relenv.runtime._build_shebang(original)( + SimpleNamespace(target_dir="/tmp/dir") + ) + shebang = result.decode().strip() + assert shebang.startswith("#!") + path_part = shebang[2:] + expected = os.fspath(pathlib.Path("/") / pathlib.Path("bin") / "python") + assert path_part == expected + + +def test_setup_openssl_version_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda _: "/usr/bin/openssl") + monkeypatch.setattr(relenv.runtime, "set_openssl_modules_dir", lambda path: None) + monkeypatch.setattr(relenv.runtime, "load_openssl_provider", lambda name: 1) + + def fake_run(args: list[str], **kwargs: object) -> SimpleNamespace: + return SimpleNamespace(returncode=1, stdout="", stderr="err") + + monkeypatch.setattr(relenv.runtime.subprocess, "run", fake_run) + relenv.runtime.setup_openssl() + + +def test_setup_openssl_parse_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda _: "/usr/bin/openssl") + monkeypatch.setattr(relenv.runtime, "load_openssl_provider", lambda name: 1) + monkeypatch.setattr(relenv.runtime, "set_openssl_modules_dir", lambda path: None) + monkeypatch.delenv("OPENSSL_MODULES", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + def fake_run(args: list[str], **kwargs: object) -> SimpleNamespace: + if "-m" in args: + return SimpleNamespace(returncode=0, stdout="invalid", stderr="") + return SimpleNamespace(returncode=0, stdout='OPENSSLDIR: "/etc/ssl"', stderr="") + + monkeypatch.setattr(relenv.runtime.subprocess, "run", fake_run) + relenv.runtime.setup_openssl() + + +def test_setup_openssl_cert_dir_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda _: "/usr/bin/openssl") + monkeypatch.setattr(relenv.runtime, "load_openssl_provider", lambda name: 1) + monkeypatch.setattr(relenv.runtime, "set_openssl_modules_dir", lambda path: None) + + def fake_run(args: list[str], **kwargs: object) -> SimpleNamespace: + if "-m" in args: + return SimpleNamespace( + returncode=0, stdout='MODULESDIR: "/usr/lib"', stderr="" + ) + return SimpleNamespace(returncode=1, stdout="", stderr="error") + + monkeypatch.setattr(relenv.runtime.subprocess, "run", fake_run) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + relenv.runtime.setup_openssl() + + +def test_setup_openssl_cert_dir_parse_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda _: "/usr/bin/openssl") + monkeypatch.setattr(relenv.runtime, "load_openssl_provider", lambda name: 1) + monkeypatch.setattr(relenv.runtime, "set_openssl_modules_dir", lambda path: None) + monkeypatch.delenv("OPENSSL_MODULES", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + def fake_run(args: list[str], **kwargs: object) -> SimpleNamespace: + if "-m" in args: + return SimpleNamespace( + returncode=0, stdout='MODULESDIR: "/usr/lib"', stderr="" + ) + return SimpleNamespace(returncode=0, stdout="invalid", stderr="") + + monkeypatch.setattr(relenv.runtime.subprocess, "run", fake_run) + relenv.runtime.setup_openssl() + + +def test_setup_openssl_cert_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda _: "/usr/bin/openssl") + cert_dir = tmp_path / "etc" / "ssl" + cert_dir.mkdir(parents=True) + cert_file = cert_dir / "cert.pem" + cert_file.write_text("cert") + + def fake_run(args: list[str], **kwargs: object) -> SimpleNamespace: + if "-m" in args: + return SimpleNamespace( + returncode=0, stdout='MODULESDIR: "{}"'.format(cert_dir), stderr="" + ) + return SimpleNamespace( + returncode=0, stdout='OPENSSLDIR: "{}"'.format(cert_dir), stderr="" + ) + + monkeypatch.setattr(relenv.runtime.subprocess, "run", fake_run) + monkeypatch.setenv("OPENSSL_MODULES", "") + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + monkeypatch.setattr(relenv.runtime, "set_openssl_modules_dir", lambda path: None) + monkeypatch.setattr(relenv.runtime, "load_openssl_provider", lambda name: 1) + relenv.runtime.setup_openssl() + assert os.environ["SSL_CERT_FILE"] == os.fspath(cert_file) + monkeypatch.delenv("OPENSSL_MODULES", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + +def test_set_openssl_modules_dir(monkeypatch: pytest.MonkeyPatch) -> None: + called = {} + + class FakeLib: + def __init__(self) -> None: + self.OSSL_PROVIDER_set_default_search_path = ( + lambda ctx, path: called.update({"path": path}) or 1 + ) + + monkeypatch.setattr(relenv.runtime.ctypes, "CDLL", lambda path: FakeLib()) + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "darwin", raising=False) + relenv.runtime.set_openssl_modules_dir("/mods") + assert called["path"] == b"/mods" + + +def test_load_openssl_provider(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeLib: + def __init__(self) -> None: + self.OSSL_PROVIDER_load = lambda ctx, name: 123 + + monkeypatch.setattr(relenv.runtime.ctypes, "CDLL", lambda path: FakeLib()) + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "darwin", raising=False) + assert relenv.runtime.load_openssl_provider("default") == 123 + + +def test_setup_crossroot( + monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path +) -> None: + monkeypatch.setenv("RELENV_CROSS", str(tmp_path)) + original_path = sys.path[:] + try: + relenv.runtime.setup_crossroot() + assert sys.prefix == str(tmp_path.resolve()) + assert str(tmp_path / "lib") in sys.path[0] + finally: + sys.path = original_path + monkeypatch.delenv("RELENV_CROSS", raising=False) + + +def test_setup_openssl_provider_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + monkeypatch.setattr(relenv.runtime.shutil, "which", lambda _: "/usr/bin/openssl") + order: list[str] = [] + monkeypatch.setattr( + relenv.runtime, "set_openssl_modules_dir", lambda path: order.append(path) + ) + providers: list[str] = [] + monkeypatch.setattr( + relenv.runtime, + "load_openssl_provider", + lambda name: providers.append(name) or 0, + ) + monkeypatch.setattr( + relenv.runtime.subprocess, + "run", + lambda args, **kwargs: SimpleNamespace( + returncode=0, + stdout='MODULESDIR: "/usr/lib"' + if "-m" in args + else 'OPENSSLDIR: "/etc/ssl"', + stderr="", + ), + ) + monkeypatch.delenv("OPENSSL_MODULES", raising=False) + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + relenv.runtime.setup_openssl() + assert order[0] == "/usr/lib" + assert order[-1].endswith("ossl-modules") + assert providers == ["fips", "default", "legacy"] + monkeypatch.delenv("SSL_CERT_DIR", raising=False) + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + + +def test_wrapsitecustomize_import_error(monkeypatch: pytest.MonkeyPatch) -> None: + def original() -> None: + pass + + class CustomError(ImportError): + def __init__(self) -> None: + super().__init__("other") + self.name = "other" + + import builtins + + orig_import = builtins.__import__ + + def fake_import( + name: str, + globals: Optional[dict] = None, + locals: Optional[dict] = None, + fromlist=(), + level: int = 0, + ): + if name == "sitecustomize": + raise CustomError() + return orig_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + monkeypatch.setattr( + relenv.runtime, + "common", + lambda: SimpleNamespace(sanitize_sys_path=lambda paths: paths), + ) + wrapped = relenv.runtime.wrapsitecustomize(original) + with pytest.raises(ImportError): + wrapped() + + +def test_wrapsitecustomize_skip(monkeypatch: pytest.MonkeyPatch) -> None: + def original() -> None: + pass + + fake_module = ModuleType("sitecustomize") + fake_module.__file__ = "/tmp/pip-build-env/sitecustomize.py" + monkeypatch.setitem(sys.modules, "sitecustomize", fake_module) + monkeypatch.setattr( + relenv.runtime, + "common", + lambda: SimpleNamespace(sanitize_sys_path=lambda paths: paths), + ) + wrapped = relenv.runtime.wrapsitecustomize(original) + monkeypatch.setattr(relenv.runtime, "debug", lambda msg: None) + wrapped() + + +def test_set_openssl_modules_dir_linux(monkeypatch: pytest.MonkeyPatch) -> None: + called = {} + + class FakeLib: + def __init__(self) -> None: + self.OSSL_PROVIDER_set_default_search_path = ( + lambda ctx, path: called.update({"path": path}) or 1 + ) + + monkeypatch.setattr(relenv.runtime.ctypes, "CDLL", lambda path: FakeLib()) + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + relenv.runtime.set_openssl_modules_dir("/mods") + assert called["path"] == b"/mods" + + +def test_load_openssl_provider_linux(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeLib: + def __init__(self) -> None: + self.OSSL_PROVIDER_load = lambda ctx, name: 456 + + monkeypatch.setattr(relenv.runtime.ctypes, "CDLL", lambda path: FakeLib()) + monkeypatch.setattr( + relenv.runtime.sys, "RELENV", pathlib.Path("/rel"), raising=False + ) + monkeypatch.setattr(relenv.runtime.sys, "platform", "linux", raising=False) + assert relenv.runtime.load_openssl_provider("default") == 456