 c03f57fd5b
			
		
	
	
		c03f57fd5b
		
	
	
	
	
		
			
			This reverts commit e8e4298feadae7924cf7600bb3bcc5b0a8d7cbe9. ensuregroup allows to specify both the acceptable versions of avocado, and a locked version to be used when avocado is not installed as a system pacakge. This lets us install avocado in pyvenv/ using "mkvenv.py" and reuse the distro package on Fedora and CentOS Stream (the only distros where it's available). ensuregroup's usage of "(>=..., <=...)" constraints when evaluating the distro package, and "==" constraints when installing it from PyPI, makes it possible to avoid conflicts between the known-good version and a package plugins included in the distro. This is because package plugins have "==" constraints on the version that is included in the distro, and, using "pip install avocado==88.1" on a venv that includes system packages will result in an error: avocado-framework-plugin-varianter-yaml-to-mux 98.0 requires avocado-framework==98.0, but you have avocado-framework 88.1 which is incompatible. avocado-framework-plugin-result-html 98.0 requires avocado-framework==98.0, but you have avocado-framework 88.1 which is incompatible. But at the same time, if the venv does not include a system distribution of avocado then we can install a known-good version and stick to LTS releases. Resolves: https://gitlab.com/qemu-project/qemu/-/issues/1663 Signed-off-by: Paolo Bonzini <pbonzini@redhat.com>
		
			
				
	
	
		
			1169 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			1169 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """
 | |
| 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
 | |
|             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,
 | |
|         )
 | |
| 
 | |
|     def _generator() -> Iterator[str]:
 | |
|         for package in packages:
 | |
|             try:
 | |
|                 entry_points = 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())
 |