| """ |
| mkvenv - QEMU pyvenv bootstrapping utility |
| |
| usage: mkvenv [-h] command ... |
| |
| QEMU pyvenv bootstrapping utility |
| |
| options: |
| -h, --help show this help message and exit |
| |
| Commands: |
| command Description |
| create create a venv |
| post_init |
| post-venv initialization |
| ensure Ensure that the specified package is installed. |
| ensuregroup |
| Ensure that the specified package group is installed. |
| |
| -------------------------------------------------- |
| |
| usage: mkvenv create [-h] target |
| |
| positional arguments: |
| target Target directory to install virtual environment into. |
| |
| options: |
| -h, --help show this help message and exit |
| |
| -------------------------------------------------- |
| |
| usage: mkvenv post_init [-h] |
| |
| options: |
| -h, --help show this help message and exit |
| |
| -------------------------------------------------- |
| |
| usage: mkvenv ensure [-h] [--online] [--dir DIR] dep_spec... |
| |
| positional arguments: |
| dep_spec PEP 508 Dependency specification, e.g. 'meson>=0.61.5' |
| |
| options: |
| -h, --help show this help message and exit |
| --online Install packages from PyPI, if necessary. |
| --dir DIR Path to vendored packages where we may install from. |
| |
| -------------------------------------------------- |
| |
| usage: mkvenv ensuregroup [-h] [--online] [--dir DIR] file group... |
| |
| positional arguments: |
| file pointer to a TOML file |
| group section name in the TOML file |
| |
| options: |
| -h, --help show this help message and exit |
| --online Install packages from PyPI, if necessary. |
| --dir DIR Path to vendored packages where we may install from. |
| |
| """ |
| |
| # The duplication between importlib and pkg_resources does not help |
| # pylint: disable=too-many-lines |
| |
| # Copyright (C) 2022-2023 Red Hat, Inc. |
| # |
| # Authors: |
| # John Snow <jsnow@redhat.com> |
| # Paolo Bonzini <pbonzini@redhat.com> |
| # |
| # This work is licensed under the terms of the GNU GPL, version 2 or |
| # later. See the COPYING file in the top-level directory. |
| |
| import argparse |
| from importlib.util import find_spec |
| import logging |
| import os |
| from pathlib import Path |
| import re |
| import shutil |
| import site |
| import subprocess |
| import sys |
| import sysconfig |
| from types import SimpleNamespace |
| from typing import ( |
| Any, |
| Dict, |
| Iterator, |
| Optional, |
| Sequence, |
| Tuple, |
| Union, |
| ) |
| import venv |
| |
| |
| # Try to load distlib, with a fallback to pip's vendored version. |
| # HAVE_DISTLIB is checked below, just-in-time, so that mkvenv does not fail |
| # outside the venv or before a potential call to ensurepip in checkpip(). |
| HAVE_DISTLIB = True |
| try: |
| import distlib.scripts |
| import distlib.version |
| except ImportError: |
| try: |
| # Reach into pip's cookie jar. pylint and flake8 don't understand |
| # that these imports will be used via distlib.xxx. |
| from pip._vendor import distlib |
| import pip._vendor.distlib.scripts # noqa, pylint: disable=unused-import |
| import pip._vendor.distlib.version # noqa, pylint: disable=unused-import |
| except ImportError: |
| HAVE_DISTLIB = False |
| |
| # Try to load tomllib, with a fallback to tomli. |
| # HAVE_TOMLLIB is checked below, just-in-time, so that mkvenv does not fail |
| # outside the venv or before a potential call to ensurepip in checkpip(). |
| HAVE_TOMLLIB = True |
| try: |
| import tomllib |
| except ImportError: |
| try: |
| import tomli as tomllib |
| except ImportError: |
| HAVE_TOMLLIB = False |
| |
| # Do not add any mandatory dependencies from outside the stdlib: |
| # This script *must* be usable standalone! |
| |
| DirType = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] |
| logger = logging.getLogger("mkvenv") |
| |
| |
| def inside_a_venv() -> bool: |
| """Returns True if it is executed inside of a virtual environment.""" |
| return sys.prefix != sys.base_prefix |
| |
| |
| class Ouch(RuntimeError): |
| """An Exception class we can't confuse with a builtin.""" |
| |
| |
| class QemuEnvBuilder(venv.EnvBuilder): |
| """ |
| An extension of venv.EnvBuilder for building QEMU's configure-time venv. |
| |
| The primary difference is that it emulates a "nested" virtual |
| environment when invoked from inside of an existing virtual |
| environment by including packages from the parent. Also, |
| "ensurepip" is replaced if possible with just recreating pip's |
| console_scripts inside the virtual environment. |
| |
| Parameters for base class init: |
| - system_site_packages: bool = False |
| - clear: bool = False |
| - symlinks: bool = False |
| - upgrade: bool = False |
| - with_pip: bool = False |
| - prompt: Optional[str] = None |
| - upgrade_deps: bool = False (Since 3.9) |
| """ |
| |
| def __init__(self, *args: Any, **kwargs: Any) -> None: |
| logger.debug("QemuEnvBuilder.__init__(...)") |
| |
| # For nested venv emulation: |
| self.use_parent_packages = False |
| if inside_a_venv(): |
| # Include parent packages only if we're in a venv and |
| # system_site_packages was True. |
| self.use_parent_packages = kwargs.pop( |
| "system_site_packages", False |
| ) |
| # Include system_site_packages only when the parent, |
| # The venv we are currently in, also does so. |
| kwargs["system_site_packages"] = sys.base_prefix in site.PREFIXES |
| |
| # ensurepip is slow: venv creation can be very fast for cases where |
| # we allow the use of system_site_packages. Therefore, ensurepip is |
| # replaced with our own script generation once the virtual environment |
| # is setup. |
| self.want_pip = kwargs.get("with_pip", False) |
| if self.want_pip: |
| if ( |
| kwargs.get("system_site_packages", False) |
| and not need_ensurepip() |
| ): |
| kwargs["with_pip"] = False |
| else: |
| check_ensurepip(suggest_remedy=True) |
| |
| super().__init__(*args, **kwargs) |
| |
| # Make the context available post-creation: |
| self._context: Optional[SimpleNamespace] = None |
| |
| def get_parent_libpath(self) -> Optional[str]: |
| """Return the libpath of the parent venv, if applicable.""" |
| if self.use_parent_packages: |
| return sysconfig.get_path("purelib") |
| return None |
| |
| @staticmethod |
| def compute_venv_libpath(context: SimpleNamespace) -> str: |
| """ |
| Compatibility wrapper for context.lib_path for Python < 3.12 |
| """ |
| # Python 3.12+, not strictly necessary because it's documented |
| # to be the same as 3.10 code below: |
| if sys.version_info >= (3, 12): |
| return context.lib_path |
| |
| # Python 3.10+ |
| if "venv" in sysconfig.get_scheme_names(): |
| lib_path = sysconfig.get_path( |
| "purelib", scheme="venv", vars={"base": context.env_dir} |
| ) |
| assert lib_path is not None |
| return lib_path |
| |
| # For Python <= 3.9 we need to hardcode this. Fortunately the |
| # code below was the same in Python 3.6-3.10, so there is only |
| # one case. |
| if sys.platform == "win32": |
| return os.path.join(context.env_dir, "Lib", "site-packages") |
| return os.path.join( |
| context.env_dir, |
| "lib", |
| "python%d.%d" % sys.version_info[:2], |
| "site-packages", |
| ) |
| |
| def ensure_directories(self, env_dir: DirType) -> SimpleNamespace: |
| logger.debug("ensure_directories(env_dir=%s)", env_dir) |
| self._context = super().ensure_directories(env_dir) |
| return self._context |
| |
| def create(self, env_dir: DirType) -> None: |
| logger.debug("create(env_dir=%s)", env_dir) |
| super().create(env_dir) |
| assert self._context is not None |
| self.post_post_setup(self._context) |
| |
| def post_post_setup(self, context: SimpleNamespace) -> None: |
| """ |
| The final, final hook. Enter the venv and run commands inside of it. |
| """ |
| if self.use_parent_packages: |
| # We're inside of a venv and we want to include the parent |
| # venv's packages. |
| parent_libpath = self.get_parent_libpath() |
| assert parent_libpath is not None |
| logger.debug("parent_libpath: %s", parent_libpath) |
| |
| our_libpath = self.compute_venv_libpath(context) |
| logger.debug("our_libpath: %s", our_libpath) |
| |
| pth_file = os.path.join(our_libpath, "nested.pth") |
| with open(pth_file, "w", encoding="UTF-8") as file: |
| file.write(parent_libpath + os.linesep) |
| |
| if self.want_pip: |
| args = [ |
| context.env_exe, |
| __file__, |
| "post_init", |
| ] |
| subprocess.run(args, check=True) |
| |
| def get_value(self, field: str) -> str: |
| """ |
| Get a string value from the context namespace after a call to build. |
| |
| For valid field names, see: |
| https://docs.python.org/3/library/venv.html#venv.EnvBuilder.ensure_directories |
| """ |
| ret = getattr(self._context, field) |
| assert isinstance(ret, str) |
| return ret |
| |
| |
| def need_ensurepip() -> bool: |
| """ |
| Tests for the presence of setuptools and pip. |
| |
| :return: `True` if we do not detect both packages. |
| """ |
| # Don't try to actually import them, it's fraught with danger: |
| # https://github.com/pypa/setuptools/issues/2993 |
| if find_spec("setuptools") and find_spec("pip"): |
| return False |
| return True |
| |
| |
| def check_ensurepip(prefix: str = "", suggest_remedy: bool = False) -> None: |
| """ |
| Check that we have ensurepip. |
| |
| Raise a fatal exception with a helpful hint if it isn't available. |
| """ |
| if not find_spec("ensurepip"): |
| msg = ( |
| "Python's ensurepip module is not found.\n" |
| "It's normally part of the Python standard library, " |
| "maybe your distribution packages it separately?\n" |
| "(Debian puts ensurepip in its python3-venv package.)\n" |
| ) |
| if suggest_remedy: |
| msg += ( |
| "Either install ensurepip, or alleviate the need for it in the" |
| " first place by installing pip and setuptools for " |
| f"'{sys.executable}'.\n" |
| ) |
| raise Ouch(prefix + msg) |
| |
| # ensurepip uses pyexpat, which can also go missing on us: |
| if not find_spec("pyexpat"): |
| msg = ( |
| "Python's pyexpat module is not found.\n" |
| "It's normally part of the Python standard library, " |
| "maybe your distribution packages it separately?\n" |
| "(NetBSD's pkgsrc debundles this to e.g. 'py310-expat'.)\n" |
| ) |
| if suggest_remedy: |
| msg += ( |
| "Either install pyexpat, or alleviate the need for it in the " |
| "first place by installing pip and setuptools for " |
| f"'{sys.executable}'.\n" |
| ) |
| raise Ouch(prefix + msg) |
| |
| |
| def make_venv( # pylint: disable=too-many-arguments |
| env_dir: Union[str, Path], |
| system_site_packages: bool = False, |
| clear: bool = True, |
| symlinks: Optional[bool] = None, |
| with_pip: bool = True, |
| ) -> None: |
| """ |
| Create a venv using `QemuEnvBuilder`. |
| |
| This is analogous to the `venv.create` module-level convenience |
| function that is part of the Python stdblib, except it uses |
| `QemuEnvBuilder` instead. |
| |
| :param env_dir: The directory to create/install to. |
| :param system_site_packages: |
| Allow inheriting packages from the system installation. |
| :param clear: When True, fully remove any prior venv and files. |
| :param symlinks: |
| Whether to use symlinks to the target interpreter or not. If |
| left unspecified, it will use symlinks except on Windows to |
| match behavior with the "venv" CLI tool. |
| :param with_pip: |
| Whether to install "pip" binaries or not. |
| """ |
| logger.debug( |
| "%s: make_venv(env_dir=%s, system_site_packages=%s, " |
| "clear=%s, symlinks=%s, with_pip=%s)", |
| __file__, |
| str(env_dir), |
| system_site_packages, |
| clear, |
| symlinks, |
| with_pip, |
| ) |
| |
| if symlinks is None: |
| # Default behavior of standard venv CLI |
| symlinks = os.name != "nt" |
| |
| builder = QemuEnvBuilder( |
| system_site_packages=system_site_packages, |
| clear=clear, |
| symlinks=symlinks, |
| with_pip=with_pip, |
| ) |
| |
| style = "non-isolated" if builder.system_site_packages else "isolated" |
| nested = "" |
| if builder.use_parent_packages: |
| nested = f"(with packages from '{builder.get_parent_libpath()}') " |
| print( |
| f"mkvenv: Creating {style} virtual environment" |
| f" {nested}at '{str(env_dir)}'", |
| file=sys.stderr, |
| ) |
| |
| try: |
| logger.debug("Invoking builder.create()") |
| try: |
| builder.create(str(env_dir)) |
| except SystemExit as exc: |
| # Some versions of the venv module raise SystemExit; *nasty*! |
| # We want the exception that prompted it. It might be a subprocess |
| # error that has output we *really* want to see. |
| logger.debug("Intercepted SystemExit from EnvBuilder.create()") |
| raise exc.__cause__ or exc.__context__ or exc |
| logger.debug("builder.create() finished") |
| except subprocess.CalledProcessError as exc: |
| logger.error("mkvenv subprocess failed:") |
| logger.error("cmd: %s", exc.cmd) |
| logger.error("returncode: %d", exc.returncode) |
| |
| def _stringify(data: Union[str, bytes]) -> str: |
| if isinstance(data, bytes): |
| return data.decode() |
| return data |
| |
| lines = [] |
| if exc.stdout: |
| lines.append("========== stdout ==========") |
| lines.append(_stringify(exc.stdout)) |
| lines.append("============================") |
| if exc.stderr: |
| lines.append("========== stderr ==========") |
| lines.append(_stringify(exc.stderr)) |
| lines.append("============================") |
| if lines: |
| logger.error(os.linesep.join(lines)) |
| |
| raise Ouch("VENV creation subprocess failed.") from exc |
| |
| # print the python executable to stdout for configure. |
| print(builder.get_value("env_exe")) |
| |
| |
| def _gen_importlib(packages: Sequence[str]) -> Iterator[str]: |
| # pylint: disable=import-outside-toplevel |
| # pylint: disable=no-name-in-module |
| # pylint: disable=import-error |
| try: |
| # First preference: Python 3.8+ stdlib |
| from importlib.metadata import ( # type: ignore |
| EntryPoint, |
| PackageNotFoundError, |
| distribution, |
| ) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| # Second preference: Commonly available PyPI backport |
| from importlib_metadata import ( # type: ignore |
| EntryPoint, |
| PackageNotFoundError, |
| distribution, |
| ) |
| |
| def _generator() -> Iterator[str]: |
| for package in packages: |
| try: |
| entry_points: Iterator[EntryPoint] = \ |
| iter(distribution(package).entry_points) |
| except PackageNotFoundError: |
| continue |
| |
| # The EntryPoints type is only available in 3.10+, |
| # treat this as a vanilla list and filter it ourselves. |
| entry_points = filter( |
| lambda ep: ep.group == "console_scripts", entry_points |
| ) |
| |
| for entry_point in entry_points: |
| yield f"{entry_point.name} = {entry_point.value}" |
| |
| return _generator() |
| |
| |
| def _gen_pkg_resources(packages: Sequence[str]) -> Iterator[str]: |
| # pylint: disable=import-outside-toplevel |
| # Bundled with setuptools; has a good chance of being available. |
| import pkg_resources |
| |
| def _generator() -> Iterator[str]: |
| for package in packages: |
| try: |
| eps = pkg_resources.get_entry_map(package, "console_scripts") |
| except pkg_resources.DistributionNotFound: |
| continue |
| |
| for entry_point in eps.values(): |
| yield str(entry_point) |
| |
| return _generator() |
| |
| |
| def generate_console_scripts( |
| packages: Sequence[str], |
| python_path: Optional[str] = None, |
| bin_path: Optional[str] = None, |
| ) -> None: |
| """ |
| Generate script shims for console_script entry points in @packages. |
| """ |
| if python_path is None: |
| python_path = sys.executable |
| if bin_path is None: |
| bin_path = sysconfig.get_path("scripts") |
| assert bin_path is not None |
| |
| logger.debug( |
| "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)", |
| packages, |
| python_path, |
| bin_path, |
| ) |
| |
| if not packages: |
| return |
| |
| def _get_entry_points() -> Iterator[str]: |
| """Python 3.7 compatibility shim for iterating entry points.""" |
| # Python 3.8+, or Python 3.7 with importlib_metadata installed. |
| try: |
| return _gen_importlib(packages) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| |
| # Python 3.7 with setuptools installed. |
| try: |
| return _gen_pkg_resources(packages) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| raise Ouch( |
| "Neither importlib.metadata nor pkg_resources found, " |
| "can't generate console script shims.\n" |
| "Use Python 3.8+, or install importlib-metadata or setuptools." |
| ) from exc |
| |
| maker = distlib.scripts.ScriptMaker(None, bin_path) |
| maker.variants = {""} |
| maker.clobber = False |
| |
| for entry_point in _get_entry_points(): |
| for filename in maker.make(entry_point): |
| logger.debug("wrote console_script '%s'", filename) |
| |
| |
| def checkpip() -> bool: |
| """ |
| Debian10 has a pip that's broken when used inside of a virtual environment. |
| |
| We try to detect and correct that case here. |
| """ |
| try: |
| # pylint: disable=import-outside-toplevel,unused-import,import-error |
| # pylint: disable=redefined-outer-name |
| import pip._internal # type: ignore # noqa: F401 |
| |
| logger.debug("pip appears to be working correctly.") |
| return False |
| except ModuleNotFoundError as exc: |
| if exc.name == "pip._internal": |
| # Uh, fair enough. They did say "internal". |
| # Let's just assume it's fine. |
| return False |
| logger.warning("pip appears to be malfunctioning: %s", str(exc)) |
| |
| check_ensurepip("pip appears to be non-functional, and ") |
| |
| logger.debug("Attempting to repair pip ...") |
| subprocess.run( |
| (sys.executable, "-m", "ensurepip"), |
| stdout=subprocess.DEVNULL, |
| check=True, |
| ) |
| logger.debug("Pip is now (hopefully) repaired!") |
| return True |
| |
| |
| def pkgname_from_depspec(dep_spec: str) -> str: |
| """ |
| Parse package name out of a PEP-508 depspec. |
| |
| See https://peps.python.org/pep-0508/#names |
| """ |
| match = re.match( |
| r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE |
| ) |
| if not match: |
| raise ValueError( |
| f"dep_spec '{dep_spec}'" |
| " does not appear to contain a valid package name" |
| ) |
| return match.group(0) |
| |
| |
| def _get_path_importlib(package: str) -> Optional[str]: |
| # pylint: disable=import-outside-toplevel |
| # pylint: disable=no-name-in-module |
| # pylint: disable=import-error |
| try: |
| # First preference: Python 3.8+ stdlib |
| from importlib.metadata import ( # type: ignore |
| PackageNotFoundError, |
| distribution, |
| ) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| # Second preference: Commonly available PyPI backport |
| from importlib_metadata import ( # type: ignore |
| PackageNotFoundError, |
| distribution, |
| ) |
| |
| try: |
| return str(distribution(package).locate_file(".")) |
| except PackageNotFoundError: |
| return None |
| |
| |
| def _get_path_pkg_resources(package: str) -> Optional[str]: |
| # pylint: disable=import-outside-toplevel |
| # Bundled with setuptools; has a good chance of being available. |
| import pkg_resources |
| |
| try: |
| return str(pkg_resources.get_distribution(package).location) |
| except pkg_resources.DistributionNotFound: |
| return None |
| |
| |
| def _get_path(package: str) -> Optional[str]: |
| try: |
| return _get_path_importlib(package) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| |
| try: |
| return _get_path_pkg_resources(package) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| raise Ouch( |
| "Neither importlib.metadata nor pkg_resources found. " |
| "Use Python 3.8+, or install importlib-metadata or setuptools." |
| ) from exc |
| |
| |
| def _path_is_prefix(prefix: Optional[str], path: str) -> bool: |
| try: |
| return ( |
| prefix is not None and os.path.commonpath([prefix, path]) == prefix |
| ) |
| except ValueError: |
| return False |
| |
| |
| def _is_system_package(package: str) -> bool: |
| path = _get_path(package) |
| return path is not None and not ( |
| _path_is_prefix(sysconfig.get_path("purelib"), path) |
| or _path_is_prefix(sysconfig.get_path("platlib"), path) |
| ) |
| |
| |
| def _get_version_importlib(package: str) -> Optional[str]: |
| # pylint: disable=import-outside-toplevel |
| # pylint: disable=no-name-in-module |
| # pylint: disable=import-error |
| try: |
| # First preference: Python 3.8+ stdlib |
| from importlib.metadata import ( # type: ignore |
| PackageNotFoundError, |
| distribution, |
| ) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| # Second preference: Commonly available PyPI backport |
| from importlib_metadata import ( # type: ignore |
| PackageNotFoundError, |
| distribution, |
| ) |
| |
| try: |
| return str(distribution(package).version) |
| except PackageNotFoundError: |
| return None |
| |
| |
| def _get_version_pkg_resources(package: str) -> Optional[str]: |
| # pylint: disable=import-outside-toplevel |
| # Bundled with setuptools; has a good chance of being available. |
| import pkg_resources |
| |
| try: |
| return str(pkg_resources.get_distribution(package).version) |
| except pkg_resources.DistributionNotFound: |
| return None |
| |
| |
| def _get_version(package: str) -> Optional[str]: |
| try: |
| return _get_version_importlib(package) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| |
| try: |
| return _get_version_pkg_resources(package) |
| except ImportError as exc: |
| logger.debug("%s", str(exc)) |
| raise Ouch( |
| "Neither importlib.metadata nor pkg_resources found. " |
| "Use Python 3.8+, or install importlib-metadata or setuptools." |
| ) from exc |
| |
| |
| def diagnose( |
| dep_spec: str, |
| online: bool, |
| wheels_dir: Optional[Union[str, Path]], |
| prog: Optional[str], |
| ) -> Tuple[str, bool]: |
| """ |
| Offer a summary to the user as to why a package failed to be installed. |
| |
| :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5' |
| :param online: Did we allow PyPI access? |
| :param prog: |
| Optionally, a shell program name that can be used as a |
| bellwether to detect if this program is installed elsewhere on |
| the system. This is used to offer advice when a program is |
| detected for a different python version. |
| :param wheels_dir: |
| Optionally, a directory that was searched for vendored packages. |
| """ |
| # pylint: disable=too-many-branches |
| |
| # Some errors are not particularly serious |
| bad = False |
| |
| pkg_name = pkgname_from_depspec(dep_spec) |
| pkg_version = _get_version(pkg_name) |
| |
| lines = [] |
| |
| if pkg_version: |
| lines.append( |
| f"Python package '{pkg_name}' version '{pkg_version}' was found," |
| " but isn't suitable." |
| ) |
| else: |
| lines.append( |
| f"Python package '{pkg_name}' was not found nor installed." |
| ) |
| |
| if wheels_dir: |
| lines.append( |
| "No suitable version found in, or failed to install from" |
| f" '{wheels_dir}'." |
| ) |
| bad = True |
| |
| if online: |
| lines.append("A suitable version could not be obtained from PyPI.") |
| bad = True |
| else: |
| lines.append( |
| "mkvenv was configured to operate offline and did not check PyPI." |
| ) |
| |
| if prog and not pkg_version: |
| which = shutil.which(prog) |
| if which: |
| if sys.base_prefix in site.PREFIXES: |
| pypath = Path(sys.executable).resolve() |
| lines.append( |
| f"'{prog}' was detected on your system at '{which}', " |
| f"but the Python package '{pkg_name}' was not found by " |
| f"this Python interpreter ('{pypath}'). " |
| f"Typically this means that '{prog}' has been installed " |
| "against a different Python interpreter on your system." |
| ) |
| else: |
| lines.append( |
| f"'{prog}' was detected on your system at '{which}', " |
| "but the build is using an isolated virtual environment." |
| ) |
| bad = True |
| |
| lines = [f" • {line}" for line in lines] |
| if bad: |
| lines.insert(0, f"Could not provide build dependency '{dep_spec}':") |
| else: |
| lines.insert(0, f"'{dep_spec}' not found:") |
| return os.linesep.join(lines), bad |
| |
| |
| def pip_install( |
| args: Sequence[str], |
| online: bool = False, |
| wheels_dir: Optional[Union[str, Path]] = None, |
| ) -> None: |
| """ |
| Use pip to install a package or package(s) as specified in @args. |
| """ |
| loud = bool( |
| os.environ.get("DEBUG") |
| or os.environ.get("GITLAB_CI") |
| or os.environ.get("V") |
| ) |
| |
| full_args = [ |
| sys.executable, |
| "-m", |
| "pip", |
| "install", |
| "--disable-pip-version-check", |
| "-v" if loud else "-q", |
| ] |
| if not online: |
| full_args += ["--no-index"] |
| if wheels_dir: |
| full_args += ["--find-links", f"file://{str(wheels_dir)}"] |
| full_args += list(args) |
| subprocess.run( |
| full_args, |
| check=True, |
| ) |
| |
| |
| def _make_version_constraint(info: Dict[str, str], install: bool) -> str: |
| """ |
| Construct the version constraint part of a PEP 508 dependency |
| specification (for example '>=0.61.5') from the accepted and |
| installed keys of the provided dictionary. |
| |
| :param info: A dictionary corresponding to a TOML key-value list. |
| :param install: True generates install constraints, False generates |
| presence constraints |
| """ |
| if install and "installed" in info: |
| return "==" + info["installed"] |
| |
| dep_spec = info.get("accepted", "") |
| dep_spec = dep_spec.strip() |
| # Double check that they didn't just use a version number |
| if dep_spec and dep_spec[0] not in "!~><=(": |
| raise Ouch( |
| "invalid dependency specifier " + dep_spec + " in dependency file" |
| ) |
| |
| return dep_spec |
| |
| |
| def _do_ensure( |
| group: Dict[str, Dict[str, str]], |
| online: bool = False, |
| wheels_dir: Optional[Union[str, Path]] = None, |
| ) -> Optional[Tuple[str, bool]]: |
| """ |
| Use pip to ensure we have the packages specified in @group. |
| |
| If the packages are already installed, do nothing. If online and |
| wheels_dir are both provided, prefer packages found in wheels_dir |
| first before connecting to PyPI. |
| |
| :param group: A dictionary of dictionaries, corresponding to a |
| section in a pythondeps.toml file. |
| :param online: If True, fall back to PyPI. |
| :param wheels_dir: If specified, search this path for packages. |
| """ |
| absent = [] |
| present = [] |
| canary = None |
| for name, info in group.items(): |
| constraint = _make_version_constraint(info, False) |
| matcher = distlib.version.LegacyMatcher(name + constraint) |
| print(f"mkvenv: checking for {matcher}", file=sys.stderr) |
| ver = _get_version(name) |
| if ( |
| ver is None |
| # Always pass installed package to pip, so that they can be |
| # updated if the requested version changes |
| or not _is_system_package(name) |
| or not matcher.match(distlib.version.LegacyVersion(ver)) |
| ): |
| absent.append(name + _make_version_constraint(info, True)) |
| if len(absent) == 1: |
| canary = info.get("canary", None) |
| else: |
| logger.info("found %s %s", name, ver) |
| present.append(name) |
| |
| if present: |
| generate_console_scripts(present) |
| |
| if absent: |
| if online or wheels_dir: |
| # Some packages are missing or aren't a suitable version, |
| # install a suitable (possibly vendored) package. |
| print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr) |
| try: |
| pip_install(args=absent, online=online, wheels_dir=wheels_dir) |
| return None |
| except subprocess.CalledProcessError: |
| pass |
| |
| return diagnose( |
| absent[0], |
| online, |
| wheels_dir, |
| canary, |
| ) |
| |
| return None |
| |
| |
| def ensure( |
| dep_specs: Sequence[str], |
| online: bool = False, |
| wheels_dir: Optional[Union[str, Path]] = None, |
| prog: Optional[str] = None, |
| ) -> None: |
| """ |
| Use pip to ensure we have the package specified by @dep_specs. |
| |
| If the package is already installed, do nothing. If online and |
| wheels_dir are both provided, prefer packages found in wheels_dir |
| first before connecting to PyPI. |
| |
| :param dep_specs: |
| PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. |
| :param online: If True, fall back to PyPI. |
| :param wheels_dir: If specified, search this path for packages. |
| :param prog: |
| If specified, use this program name for error diagnostics that will |
| be presented to the user. e.g., 'sphinx-build' can be used as a |
| bellwether for the presence of 'sphinx'. |
| """ |
| |
| if not HAVE_DISTLIB: |
| raise Ouch("a usable distlib could not be found, please install it") |
| |
| # Convert the depspecs to a dictionary, as if they came |
| # from a section in a pythondeps.toml file |
| group: Dict[str, Dict[str, str]] = {} |
| for spec in dep_specs: |
| name = distlib.version.LegacyMatcher(spec).name |
| group[name] = {} |
| |
| spec = spec.strip() |
| pos = len(name) |
| ver = spec[pos:].strip() |
| if ver: |
| group[name]["accepted"] = ver |
| |
| if prog: |
| group[name]["canary"] = prog |
| prog = None |
| |
| result = _do_ensure(group, online, wheels_dir) |
| if result: |
| # Well, that's not good. |
| if result[1]: |
| raise Ouch(result[0]) |
| raise SystemExit(f"\n{result[0]}\n\n") |
| |
| |
| def _parse_groups(file: str) -> Dict[str, Dict[str, Any]]: |
| if not HAVE_TOMLLIB: |
| if sys.version_info < (3, 11): |
| raise Ouch("found no usable tomli, please install it") |
| |
| raise Ouch( |
| "Python >=3.11 does not have tomllib... what have you done!?" |
| ) |
| |
| # Use loads() to support both tomli v1.2.x (Ubuntu 22.04, |
| # Debian bullseye-backports) and v2.0.x |
| with open(file, "r", encoding="ascii") as depfile: |
| contents = depfile.read() |
| return tomllib.loads(contents) # type: ignore |
| |
| |
| def ensure_group( |
| file: str, |
| groups: Sequence[str], |
| online: bool = False, |
| wheels_dir: Optional[Union[str, Path]] = None, |
| ) -> None: |
| """ |
| Use pip to ensure we have the package specified by @dep_specs. |
| |
| If the package is already installed, do nothing. If online and |
| wheels_dir are both provided, prefer packages found in wheels_dir |
| first before connecting to PyPI. |
| |
| :param dep_specs: |
| PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. |
| :param online: If True, fall back to PyPI. |
| :param wheels_dir: If specified, search this path for packages. |
| """ |
| |
| if not HAVE_DISTLIB: |
| raise Ouch("found no usable distlib, please install it") |
| |
| parsed_deps = _parse_groups(file) |
| |
| to_install: Dict[str, Dict[str, str]] = {} |
| for group in groups: |
| try: |
| to_install.update(parsed_deps[group]) |
| except KeyError as exc: |
| raise Ouch(f"group {group} not defined") from exc |
| |
| result = _do_ensure(to_install, online, wheels_dir) |
| if result: |
| # Well, that's not good. |
| if result[1]: |
| raise Ouch(result[0]) |
| raise SystemExit(f"\n{result[0]}\n\n") |
| |
| |
| def post_venv_setup() -> None: |
| """ |
| This is intended to be run *inside the venv* after it is created. |
| """ |
| logger.debug("post_venv_setup()") |
| # Test for a broken pip (Debian 10 or derivative?) and fix it if needed |
| if not checkpip(): |
| # Finally, generate a 'pip' script so the venv is usable in a normal |
| # way from the CLI. This only happens when we inherited pip from a |
| # parent/system-site and haven't run ensurepip in some way. |
| generate_console_scripts(["pip"]) |
| |
| |
| def _add_create_subcommand(subparsers: Any) -> None: |
| subparser = subparsers.add_parser("create", help="create a venv") |
| subparser.add_argument( |
| "target", |
| type=str, |
| action="store", |
| help="Target directory to install virtual environment into.", |
| ) |
| |
| |
| def _add_post_init_subcommand(subparsers: Any) -> None: |
| subparsers.add_parser("post_init", help="post-venv initialization") |
| |
| |
| def _add_ensuregroup_subcommand(subparsers: Any) -> None: |
| subparser = subparsers.add_parser( |
| "ensuregroup", |
| help="Ensure that the specified package group is installed.", |
| ) |
| subparser.add_argument( |
| "--online", |
| action="store_true", |
| help="Install packages from PyPI, if necessary.", |
| ) |
| subparser.add_argument( |
| "--dir", |
| type=str, |
| action="store", |
| help="Path to vendored packages where we may install from.", |
| ) |
| subparser.add_argument( |
| "file", |
| type=str, |
| action="store", |
| help=("Path to a TOML file describing package groups"), |
| ) |
| subparser.add_argument( |
| "group", |
| type=str, |
| action="store", |
| help="One or more package group names", |
| nargs="+", |
| ) |
| |
| |
| def _add_ensure_subcommand(subparsers: Any) -> None: |
| subparser = subparsers.add_parser( |
| "ensure", help="Ensure that the specified package is installed." |
| ) |
| subparser.add_argument( |
| "--online", |
| action="store_true", |
| help="Install packages from PyPI, if necessary.", |
| ) |
| subparser.add_argument( |
| "--dir", |
| type=str, |
| action="store", |
| help="Path to vendored packages where we may install from.", |
| ) |
| subparser.add_argument( |
| "--diagnose", |
| type=str, |
| action="store", |
| help=( |
| "Name of a shell utility to use for " |
| "diagnostics if this command fails." |
| ), |
| ) |
| subparser.add_argument( |
| "dep_specs", |
| type=str, |
| action="store", |
| help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'", |
| nargs="+", |
| ) |
| |
| |
| def main() -> int: |
| """CLI interface to make_qemu_venv. See module docstring.""" |
| if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"): |
| # You're welcome. |
| logging.basicConfig(level=logging.DEBUG) |
| else: |
| if os.environ.get("V"): |
| logging.basicConfig(level=logging.INFO) |
| |
| parser = argparse.ArgumentParser( |
| prog="mkvenv", |
| description="QEMU pyvenv bootstrapping utility", |
| ) |
| subparsers = parser.add_subparsers( |
| title="Commands", |
| dest="command", |
| required=True, |
| metavar="command", |
| help="Description", |
| ) |
| |
| _add_create_subcommand(subparsers) |
| _add_post_init_subcommand(subparsers) |
| _add_ensure_subcommand(subparsers) |
| _add_ensuregroup_subcommand(subparsers) |
| |
| args = parser.parse_args() |
| try: |
| if args.command == "create": |
| make_venv( |
| args.target, |
| system_site_packages=True, |
| clear=True, |
| ) |
| if args.command == "post_init": |
| post_venv_setup() |
| if args.command == "ensure": |
| ensure( |
| dep_specs=args.dep_specs, |
| online=args.online, |
| wheels_dir=args.dir, |
| prog=args.diagnose, |
| ) |
| if args.command == "ensuregroup": |
| ensure_group( |
| file=args.file, |
| groups=args.group, |
| online=args.online, |
| wheels_dir=args.dir, |
| ) |
| logger.debug("mkvenv.py %s: exiting", args.command) |
| except Ouch as exc: |
| print("\n*** Ouch! ***\n", file=sys.stderr) |
| print(str(exc), "\n\n", file=sys.stderr) |
| return 1 |
| except SystemExit: |
| raise |
| except: # pylint: disable=bare-except |
| logger.exception("mkvenv did not complete successfully:") |
| return 2 |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |