1dd84028fSJohn Snow""" 2dd84028fSJohn Snowmkvenv - QEMU pyvenv bootstrapping utility 3dd84028fSJohn Snow 4dd84028fSJohn Snowusage: mkvenv [-h] command ... 5dd84028fSJohn Snow 6dd84028fSJohn SnowQEMU pyvenv bootstrapping utility 7dd84028fSJohn Snow 8dd84028fSJohn Snowoptions: 9dd84028fSJohn Snow -h, --help show this help message and exit 10dd84028fSJohn Snow 11dd84028fSJohn SnowCommands: 12dd84028fSJohn Snow command Description 13dd84028fSJohn Snow create create a venv 14c5538eedSJohn Snow ensure Ensure that the specified package is installed. 15dd84028fSJohn Snow 16dd84028fSJohn Snow-------------------------------------------------- 17dd84028fSJohn Snow 18dd84028fSJohn Snowusage: mkvenv create [-h] target 19dd84028fSJohn Snow 20dd84028fSJohn Snowpositional arguments: 21dd84028fSJohn Snow target Target directory to install virtual environment into. 22dd84028fSJohn Snow 23dd84028fSJohn Snowoptions: 24dd84028fSJohn Snow -h, --help show this help message and exit 25dd84028fSJohn Snow 26c5538eedSJohn Snow-------------------------------------------------- 27c5538eedSJohn Snow 28c5538eedSJohn Snowusage: mkvenv ensure [-h] [--online] [--dir DIR] dep_spec... 29c5538eedSJohn Snow 30c5538eedSJohn Snowpositional arguments: 31c5538eedSJohn Snow dep_spec PEP 508 Dependency specification, e.g. 'meson>=0.61.5' 32c5538eedSJohn Snow 33c5538eedSJohn Snowoptions: 34c5538eedSJohn Snow -h, --help show this help message and exit 35c5538eedSJohn Snow --online Install packages from PyPI, if necessary. 36c5538eedSJohn Snow --dir DIR Path to vendored packages where we may install from. 37c5538eedSJohn Snow 38dd84028fSJohn Snow""" 39dd84028fSJohn Snow 40dd84028fSJohn Snow# Copyright (C) 2022-2023 Red Hat, Inc. 41dd84028fSJohn Snow# 42dd84028fSJohn Snow# Authors: 43dd84028fSJohn Snow# John Snow <jsnow@redhat.com> 44dd84028fSJohn Snow# Paolo Bonzini <pbonzini@redhat.com> 45dd84028fSJohn Snow# 46dd84028fSJohn Snow# This work is licensed under the terms of the GNU GPL, version 2 or 47dd84028fSJohn Snow# later. See the COPYING file in the top-level directory. 48dd84028fSJohn Snow 49dd84028fSJohn Snowimport argparse 50a9dbde71SJohn Snowfrom importlib.util import find_spec 51dd84028fSJohn Snowimport logging 52dd84028fSJohn Snowimport os 53dd84028fSJohn Snowfrom pathlib import Path 544695a22eSJohn Snowimport re 554695a22eSJohn Snowimport shutil 56dee01b82SJohn Snowimport site 57dd84028fSJohn Snowimport subprocess 58dd84028fSJohn Snowimport sys 59dee01b82SJohn Snowimport sysconfig 60dd84028fSJohn Snowfrom types import SimpleNamespace 61c5538eedSJohn Snowfrom typing import ( 62c5538eedSJohn Snow Any, 6392834894SJohn Snow Iterator, 64c5538eedSJohn Snow Optional, 65c5538eedSJohn Snow Sequence, 664695a22eSJohn Snow Tuple, 67c5538eedSJohn Snow Union, 68c5538eedSJohn Snow) 69dd84028fSJohn Snowimport venv 70c5538eedSJohn Snowimport warnings 71c5538eedSJohn Snow 72*68ea6d17SJohn Snow 73*68ea6d17SJohn Snow# Try to load distlib, with a fallback to pip's vendored version. 74*68ea6d17SJohn Snow# HAVE_DISTLIB is checked below, just-in-time, so that mkvenv does not fail 75*68ea6d17SJohn Snow# outside the venv or before a potential call to ensurepip in checkpip(). 76*68ea6d17SJohn SnowHAVE_DISTLIB = True 77*68ea6d17SJohn Snowtry: 78c5538eedSJohn Snow import distlib.database 7992834894SJohn Snow import distlib.scripts 80c5538eedSJohn Snow import distlib.version 81*68ea6d17SJohn Snowexcept ImportError: 82*68ea6d17SJohn Snow try: 83*68ea6d17SJohn Snow # Reach into pip's cookie jar. pylint and flake8 don't understand 84*68ea6d17SJohn Snow # that these imports will be used via distlib.xxx. 85*68ea6d17SJohn Snow from pip._vendor import distlib 86*68ea6d17SJohn Snow import pip._vendor.distlib.database # noqa, pylint: disable=unused-import 87*68ea6d17SJohn Snow import pip._vendor.distlib.scripts # noqa, pylint: disable=unused-import 88*68ea6d17SJohn Snow import pip._vendor.distlib.version # noqa, pylint: disable=unused-import 89*68ea6d17SJohn Snow except ImportError: 90*68ea6d17SJohn Snow HAVE_DISTLIB = False 91dd84028fSJohn Snow 92dd84028fSJohn Snow# Do not add any mandatory dependencies from outside the stdlib: 93dd84028fSJohn Snow# This script *must* be usable standalone! 94dd84028fSJohn Snow 95dd84028fSJohn SnowDirType = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] 96dd84028fSJohn Snowlogger = logging.getLogger("mkvenv") 97dd84028fSJohn Snow 98dd84028fSJohn Snow 99dee01b82SJohn Snowdef inside_a_venv() -> bool: 100dee01b82SJohn Snow """Returns True if it is executed inside of a virtual environment.""" 101dee01b82SJohn Snow return sys.prefix != sys.base_prefix 102dee01b82SJohn Snow 103dee01b82SJohn Snow 104dd84028fSJohn Snowclass Ouch(RuntimeError): 105dd84028fSJohn Snow """An Exception class we can't confuse with a builtin.""" 106dd84028fSJohn Snow 107dd84028fSJohn Snow 108dd84028fSJohn Snowclass QemuEnvBuilder(venv.EnvBuilder): 109dd84028fSJohn Snow """ 110dd84028fSJohn Snow An extension of venv.EnvBuilder for building QEMU's configure-time venv. 111dd84028fSJohn Snow 112dee01b82SJohn Snow The primary difference is that it emulates a "nested" virtual 113dee01b82SJohn Snow environment when invoked from inside of an existing virtual 114dee01b82SJohn Snow environment by including packages from the parent. 115dd84028fSJohn Snow 116dd84028fSJohn Snow Parameters for base class init: 117dd84028fSJohn Snow - system_site_packages: bool = False 118dd84028fSJohn Snow - clear: bool = False 119dd84028fSJohn Snow - symlinks: bool = False 120dd84028fSJohn Snow - upgrade: bool = False 121dd84028fSJohn Snow - with_pip: bool = False 122dd84028fSJohn Snow - prompt: Optional[str] = None 123dd84028fSJohn Snow - upgrade_deps: bool = False (Since 3.9) 124dd84028fSJohn Snow """ 125dd84028fSJohn Snow 126dd84028fSJohn Snow def __init__(self, *args: Any, **kwargs: Any) -> None: 127dd84028fSJohn Snow logger.debug("QemuEnvBuilder.__init__(...)") 128a9dbde71SJohn Snow 129dee01b82SJohn Snow # For nested venv emulation: 130dee01b82SJohn Snow self.use_parent_packages = False 131dee01b82SJohn Snow if inside_a_venv(): 132dee01b82SJohn Snow # Include parent packages only if we're in a venv and 133dee01b82SJohn Snow # system_site_packages was True. 134dee01b82SJohn Snow self.use_parent_packages = kwargs.pop( 135dee01b82SJohn Snow "system_site_packages", False 136dee01b82SJohn Snow ) 137dee01b82SJohn Snow # Include system_site_packages only when the parent, 138dee01b82SJohn Snow # The venv we are currently in, also does so. 139dee01b82SJohn Snow kwargs["system_site_packages"] = sys.base_prefix in site.PREFIXES 140dee01b82SJohn Snow 141a9dbde71SJohn Snow if kwargs.get("with_pip", False): 142a9dbde71SJohn Snow check_ensurepip() 143a9dbde71SJohn Snow 144dd84028fSJohn Snow super().__init__(*args, **kwargs) 145dd84028fSJohn Snow 146dd84028fSJohn Snow # Make the context available post-creation: 147dd84028fSJohn Snow self._context: Optional[SimpleNamespace] = None 148dd84028fSJohn Snow 149dee01b82SJohn Snow def get_parent_libpath(self) -> Optional[str]: 150dee01b82SJohn Snow """Return the libpath of the parent venv, if applicable.""" 151dee01b82SJohn Snow if self.use_parent_packages: 152dee01b82SJohn Snow return sysconfig.get_path("purelib") 153dee01b82SJohn Snow return None 154dee01b82SJohn Snow 155dee01b82SJohn Snow @staticmethod 156dee01b82SJohn Snow def compute_venv_libpath(context: SimpleNamespace) -> str: 157dee01b82SJohn Snow """ 158dee01b82SJohn Snow Compatibility wrapper for context.lib_path for Python < 3.12 159dee01b82SJohn Snow """ 160dee01b82SJohn Snow # Python 3.12+, not strictly necessary because it's documented 161dee01b82SJohn Snow # to be the same as 3.10 code below: 162dee01b82SJohn Snow if sys.version_info >= (3, 12): 163dee01b82SJohn Snow return context.lib_path 164dee01b82SJohn Snow 165dee01b82SJohn Snow # Python 3.10+ 166dee01b82SJohn Snow if "venv" in sysconfig.get_scheme_names(): 167dee01b82SJohn Snow lib_path = sysconfig.get_path( 168dee01b82SJohn Snow "purelib", scheme="venv", vars={"base": context.env_dir} 169dee01b82SJohn Snow ) 170dee01b82SJohn Snow assert lib_path is not None 171dee01b82SJohn Snow return lib_path 172dee01b82SJohn Snow 173dee01b82SJohn Snow # For Python <= 3.9 we need to hardcode this. Fortunately the 174dee01b82SJohn Snow # code below was the same in Python 3.6-3.10, so there is only 175dee01b82SJohn Snow # one case. 176dee01b82SJohn Snow if sys.platform == "win32": 177dee01b82SJohn Snow return os.path.join(context.env_dir, "Lib", "site-packages") 178dee01b82SJohn Snow return os.path.join( 179dee01b82SJohn Snow context.env_dir, 180dee01b82SJohn Snow "lib", 181dee01b82SJohn Snow "python%d.%d" % sys.version_info[:2], 182dee01b82SJohn Snow "site-packages", 183dee01b82SJohn Snow ) 184dee01b82SJohn Snow 185dd84028fSJohn Snow def ensure_directories(self, env_dir: DirType) -> SimpleNamespace: 186dd84028fSJohn Snow logger.debug("ensure_directories(env_dir=%s)", env_dir) 187dd84028fSJohn Snow self._context = super().ensure_directories(env_dir) 188dd84028fSJohn Snow return self._context 189dd84028fSJohn Snow 190dee01b82SJohn Snow def create(self, env_dir: DirType) -> None: 191dee01b82SJohn Snow logger.debug("create(env_dir=%s)", env_dir) 192dee01b82SJohn Snow super().create(env_dir) 193dee01b82SJohn Snow assert self._context is not None 194dee01b82SJohn Snow self.post_post_setup(self._context) 195dee01b82SJohn Snow 196dee01b82SJohn Snow def post_post_setup(self, context: SimpleNamespace) -> None: 197dee01b82SJohn Snow """ 198dee01b82SJohn Snow The final, final hook. Enter the venv and run commands inside of it. 199dee01b82SJohn Snow """ 200dee01b82SJohn Snow if self.use_parent_packages: 201dee01b82SJohn Snow # We're inside of a venv and we want to include the parent 202dee01b82SJohn Snow # venv's packages. 203dee01b82SJohn Snow parent_libpath = self.get_parent_libpath() 204dee01b82SJohn Snow assert parent_libpath is not None 205dee01b82SJohn Snow logger.debug("parent_libpath: %s", parent_libpath) 206dee01b82SJohn Snow 207dee01b82SJohn Snow our_libpath = self.compute_venv_libpath(context) 208dee01b82SJohn Snow logger.debug("our_libpath: %s", our_libpath) 209dee01b82SJohn Snow 210dee01b82SJohn Snow pth_file = os.path.join(our_libpath, "nested.pth") 211dee01b82SJohn Snow with open(pth_file, "w", encoding="UTF-8") as file: 212dee01b82SJohn Snow file.write(parent_libpath + os.linesep) 213dee01b82SJohn Snow 214dd84028fSJohn Snow def get_value(self, field: str) -> str: 215dd84028fSJohn Snow """ 216dd84028fSJohn Snow Get a string value from the context namespace after a call to build. 217dd84028fSJohn Snow 218dd84028fSJohn Snow For valid field names, see: 219dd84028fSJohn Snow https://docs.python.org/3/library/venv.html#venv.EnvBuilder.ensure_directories 220dd84028fSJohn Snow """ 221dd84028fSJohn Snow ret = getattr(self._context, field) 222dd84028fSJohn Snow assert isinstance(ret, str) 223dd84028fSJohn Snow return ret 224dd84028fSJohn Snow 225dd84028fSJohn Snow 226a9dbde71SJohn Snowdef check_ensurepip() -> None: 227a9dbde71SJohn Snow """ 228a9dbde71SJohn Snow Check that we have ensurepip. 229a9dbde71SJohn Snow 230a9dbde71SJohn Snow Raise a fatal exception with a helpful hint if it isn't available. 231a9dbde71SJohn Snow """ 232a9dbde71SJohn Snow if not find_spec("ensurepip"): 233a9dbde71SJohn Snow msg = ( 234a9dbde71SJohn Snow "Python's ensurepip module is not found.\n" 235a9dbde71SJohn Snow "It's normally part of the Python standard library, " 236a9dbde71SJohn Snow "maybe your distribution packages it separately?\n" 237a9dbde71SJohn Snow "Either install ensurepip, or alleviate the need for it in the " 238a9dbde71SJohn Snow "first place by installing pip and setuptools for " 239a9dbde71SJohn Snow f"'{sys.executable}'.\n" 240a9dbde71SJohn Snow "(Hint: Debian puts ensurepip in its python3-venv package.)" 241a9dbde71SJohn Snow ) 242a9dbde71SJohn Snow raise Ouch(msg) 243a9dbde71SJohn Snow 244a9dbde71SJohn Snow # ensurepip uses pyexpat, which can also go missing on us: 245a9dbde71SJohn Snow if not find_spec("pyexpat"): 246a9dbde71SJohn Snow msg = ( 247a9dbde71SJohn Snow "Python's pyexpat module is not found.\n" 248a9dbde71SJohn Snow "It's normally part of the Python standard library, " 249a9dbde71SJohn Snow "maybe your distribution packages it separately?\n" 250a9dbde71SJohn Snow "Either install pyexpat, or alleviate the need for it in the " 251a9dbde71SJohn Snow "first place by installing pip and setuptools for " 252a9dbde71SJohn Snow f"'{sys.executable}'.\n\n" 253a9dbde71SJohn Snow "(Hint: NetBSD's pkgsrc debundles this to e.g. 'py310-expat'.)" 254a9dbde71SJohn Snow ) 255a9dbde71SJohn Snow raise Ouch(msg) 256a9dbde71SJohn Snow 257a9dbde71SJohn Snow 258dd84028fSJohn Snowdef make_venv( # pylint: disable=too-many-arguments 259dd84028fSJohn Snow env_dir: Union[str, Path], 260dd84028fSJohn Snow system_site_packages: bool = False, 261dd84028fSJohn Snow clear: bool = True, 262dd84028fSJohn Snow symlinks: Optional[bool] = None, 263dd84028fSJohn Snow with_pip: bool = True, 264dd84028fSJohn Snow) -> None: 265dd84028fSJohn Snow """ 266dd84028fSJohn Snow Create a venv using `QemuEnvBuilder`. 267dd84028fSJohn Snow 268dd84028fSJohn Snow This is analogous to the `venv.create` module-level convenience 269dd84028fSJohn Snow function that is part of the Python stdblib, except it uses 270dd84028fSJohn Snow `QemuEnvBuilder` instead. 271dd84028fSJohn Snow 272dd84028fSJohn Snow :param env_dir: The directory to create/install to. 273dd84028fSJohn Snow :param system_site_packages: 274dd84028fSJohn Snow Allow inheriting packages from the system installation. 275dd84028fSJohn Snow :param clear: When True, fully remove any prior venv and files. 276dd84028fSJohn Snow :param symlinks: 277dd84028fSJohn Snow Whether to use symlinks to the target interpreter or not. If 278dd84028fSJohn Snow left unspecified, it will use symlinks except on Windows to 279dd84028fSJohn Snow match behavior with the "venv" CLI tool. 280dd84028fSJohn Snow :param with_pip: 281dd84028fSJohn Snow Whether to install "pip" binaries or not. 282dd84028fSJohn Snow """ 283dd84028fSJohn Snow logger.debug( 284dd84028fSJohn Snow "%s: make_venv(env_dir=%s, system_site_packages=%s, " 285dd84028fSJohn Snow "clear=%s, symlinks=%s, with_pip=%s)", 286dd84028fSJohn Snow __file__, 287dd84028fSJohn Snow str(env_dir), 288dd84028fSJohn Snow system_site_packages, 289dd84028fSJohn Snow clear, 290dd84028fSJohn Snow symlinks, 291dd84028fSJohn Snow with_pip, 292dd84028fSJohn Snow ) 293dd84028fSJohn Snow 294dd84028fSJohn Snow if symlinks is None: 295dd84028fSJohn Snow # Default behavior of standard venv CLI 296dd84028fSJohn Snow symlinks = os.name != "nt" 297dd84028fSJohn Snow 298dd84028fSJohn Snow builder = QemuEnvBuilder( 299dd84028fSJohn Snow system_site_packages=system_site_packages, 300dd84028fSJohn Snow clear=clear, 301dd84028fSJohn Snow symlinks=symlinks, 302dd84028fSJohn Snow with_pip=with_pip, 303dd84028fSJohn Snow ) 304dd84028fSJohn Snow 305dd84028fSJohn Snow style = "non-isolated" if builder.system_site_packages else "isolated" 306dee01b82SJohn Snow nested = "" 307dee01b82SJohn Snow if builder.use_parent_packages: 308dee01b82SJohn Snow nested = f"(with packages from '{builder.get_parent_libpath()}') " 309dd84028fSJohn Snow print( 310dd84028fSJohn Snow f"mkvenv: Creating {style} virtual environment" 311dee01b82SJohn Snow f" {nested}at '{str(env_dir)}'", 312dd84028fSJohn Snow file=sys.stderr, 313dd84028fSJohn Snow ) 314dd84028fSJohn Snow 315dd84028fSJohn Snow try: 316dd84028fSJohn Snow logger.debug("Invoking builder.create()") 317dd84028fSJohn Snow try: 318dd84028fSJohn Snow builder.create(str(env_dir)) 319dd84028fSJohn Snow except SystemExit as exc: 320dd84028fSJohn Snow # Some versions of the venv module raise SystemExit; *nasty*! 321dd84028fSJohn Snow # We want the exception that prompted it. It might be a subprocess 322dd84028fSJohn Snow # error that has output we *really* want to see. 323dd84028fSJohn Snow logger.debug("Intercepted SystemExit from EnvBuilder.create()") 324dd84028fSJohn Snow raise exc.__cause__ or exc.__context__ or exc 325dd84028fSJohn Snow logger.debug("builder.create() finished") 326dd84028fSJohn Snow except subprocess.CalledProcessError as exc: 327dd84028fSJohn Snow logger.error("mkvenv subprocess failed:") 328dd84028fSJohn Snow logger.error("cmd: %s", exc.cmd) 329dd84028fSJohn Snow logger.error("returncode: %d", exc.returncode) 330dd84028fSJohn Snow 331dd84028fSJohn Snow def _stringify(data: Union[str, bytes]) -> str: 332dd84028fSJohn Snow if isinstance(data, bytes): 333dd84028fSJohn Snow return data.decode() 334dd84028fSJohn Snow return data 335dd84028fSJohn Snow 336dd84028fSJohn Snow lines = [] 337dd84028fSJohn Snow if exc.stdout: 338dd84028fSJohn Snow lines.append("========== stdout ==========") 339dd84028fSJohn Snow lines.append(_stringify(exc.stdout)) 340dd84028fSJohn Snow lines.append("============================") 341dd84028fSJohn Snow if exc.stderr: 342dd84028fSJohn Snow lines.append("========== stderr ==========") 343dd84028fSJohn Snow lines.append(_stringify(exc.stderr)) 344dd84028fSJohn Snow lines.append("============================") 345dd84028fSJohn Snow if lines: 346dd84028fSJohn Snow logger.error(os.linesep.join(lines)) 347dd84028fSJohn Snow 348dd84028fSJohn Snow raise Ouch("VENV creation subprocess failed.") from exc 349dd84028fSJohn Snow 350dd84028fSJohn Snow # print the python executable to stdout for configure. 351dd84028fSJohn Snow print(builder.get_value("env_exe")) 352dd84028fSJohn Snow 353dd84028fSJohn Snow 35492834894SJohn Snowdef _gen_importlib(packages: Sequence[str]) -> Iterator[str]: 35592834894SJohn Snow # pylint: disable=import-outside-toplevel 35692834894SJohn Snow # pylint: disable=no-name-in-module 35792834894SJohn Snow # pylint: disable=import-error 35892834894SJohn Snow try: 35992834894SJohn Snow # First preference: Python 3.8+ stdlib 36092834894SJohn Snow from importlib.metadata import ( # type: ignore 36192834894SJohn Snow PackageNotFoundError, 36292834894SJohn Snow distribution, 36392834894SJohn Snow ) 36492834894SJohn Snow except ImportError as exc: 36592834894SJohn Snow logger.debug("%s", str(exc)) 36692834894SJohn Snow # Second preference: Commonly available PyPI backport 36792834894SJohn Snow from importlib_metadata import ( # type: ignore 36892834894SJohn Snow PackageNotFoundError, 36992834894SJohn Snow distribution, 37092834894SJohn Snow ) 37192834894SJohn Snow 37292834894SJohn Snow def _generator() -> Iterator[str]: 37392834894SJohn Snow for package in packages: 37492834894SJohn Snow try: 37592834894SJohn Snow entry_points = distribution(package).entry_points 37692834894SJohn Snow except PackageNotFoundError: 37792834894SJohn Snow continue 37892834894SJohn Snow 37992834894SJohn Snow # The EntryPoints type is only available in 3.10+, 38092834894SJohn Snow # treat this as a vanilla list and filter it ourselves. 38192834894SJohn Snow entry_points = filter( 38292834894SJohn Snow lambda ep: ep.group == "console_scripts", entry_points 38392834894SJohn Snow ) 38492834894SJohn Snow 38592834894SJohn Snow for entry_point in entry_points: 38692834894SJohn Snow yield f"{entry_point.name} = {entry_point.value}" 38792834894SJohn Snow 38892834894SJohn Snow return _generator() 38992834894SJohn Snow 39092834894SJohn Snow 39192834894SJohn Snowdef _gen_pkg_resources(packages: Sequence[str]) -> Iterator[str]: 39292834894SJohn Snow # pylint: disable=import-outside-toplevel 39392834894SJohn Snow # Bundled with setuptools; has a good chance of being available. 39492834894SJohn Snow import pkg_resources 39592834894SJohn Snow 39692834894SJohn Snow def _generator() -> Iterator[str]: 39792834894SJohn Snow for package in packages: 39892834894SJohn Snow try: 39992834894SJohn Snow eps = pkg_resources.get_entry_map(package, "console_scripts") 40092834894SJohn Snow except pkg_resources.DistributionNotFound: 40192834894SJohn Snow continue 40292834894SJohn Snow 40392834894SJohn Snow for entry_point in eps.values(): 40492834894SJohn Snow yield str(entry_point) 40592834894SJohn Snow 40692834894SJohn Snow return _generator() 40792834894SJohn Snow 40892834894SJohn Snow 40992834894SJohn Snowdef generate_console_scripts( 41092834894SJohn Snow packages: Sequence[str], 41192834894SJohn Snow python_path: Optional[str] = None, 41292834894SJohn Snow bin_path: Optional[str] = None, 41392834894SJohn Snow) -> None: 41492834894SJohn Snow """ 41592834894SJohn Snow Generate script shims for console_script entry points in @packages. 41692834894SJohn Snow """ 41792834894SJohn Snow if python_path is None: 41892834894SJohn Snow python_path = sys.executable 41992834894SJohn Snow if bin_path is None: 42092834894SJohn Snow bin_path = sysconfig.get_path("scripts") 42192834894SJohn Snow assert bin_path is not None 42292834894SJohn Snow 42392834894SJohn Snow logger.debug( 42492834894SJohn Snow "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)", 42592834894SJohn Snow packages, 42692834894SJohn Snow python_path, 42792834894SJohn Snow bin_path, 42892834894SJohn Snow ) 42992834894SJohn Snow 43092834894SJohn Snow if not packages: 43192834894SJohn Snow return 43292834894SJohn Snow 43392834894SJohn Snow def _get_entry_points() -> Iterator[str]: 43492834894SJohn Snow """Python 3.7 compatibility shim for iterating entry points.""" 43592834894SJohn Snow # Python 3.8+, or Python 3.7 with importlib_metadata installed. 43692834894SJohn Snow try: 43792834894SJohn Snow return _gen_importlib(packages) 43892834894SJohn Snow except ImportError as exc: 43992834894SJohn Snow logger.debug("%s", str(exc)) 44092834894SJohn Snow 44192834894SJohn Snow # Python 3.7 with setuptools installed. 44292834894SJohn Snow try: 44392834894SJohn Snow return _gen_pkg_resources(packages) 44492834894SJohn Snow except ImportError as exc: 44592834894SJohn Snow logger.debug("%s", str(exc)) 44692834894SJohn Snow raise Ouch( 44792834894SJohn Snow "Neither importlib.metadata nor pkg_resources found, " 44892834894SJohn Snow "can't generate console script shims.\n" 44992834894SJohn Snow "Use Python 3.8+, or install importlib-metadata or setuptools." 45092834894SJohn Snow ) from exc 45192834894SJohn Snow 45292834894SJohn Snow maker = distlib.scripts.ScriptMaker(None, bin_path) 45392834894SJohn Snow maker.variants = {""} 45492834894SJohn Snow maker.clobber = False 45592834894SJohn Snow 45692834894SJohn Snow for entry_point in _get_entry_points(): 45792834894SJohn Snow for filename in maker.make(entry_point): 45892834894SJohn Snow logger.debug("wrote console_script '%s'", filename) 45992834894SJohn Snow 46092834894SJohn Snow 4614695a22eSJohn Snowdef pkgname_from_depspec(dep_spec: str) -> str: 4624695a22eSJohn Snow """ 4634695a22eSJohn Snow Parse package name out of a PEP-508 depspec. 4644695a22eSJohn Snow 4654695a22eSJohn Snow See https://peps.python.org/pep-0508/#names 4664695a22eSJohn Snow """ 4674695a22eSJohn Snow match = re.match( 4684695a22eSJohn Snow r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE 4694695a22eSJohn Snow ) 4704695a22eSJohn Snow if not match: 4714695a22eSJohn Snow raise ValueError( 4724695a22eSJohn Snow f"dep_spec '{dep_spec}'" 4734695a22eSJohn Snow " does not appear to contain a valid package name" 4744695a22eSJohn Snow ) 4754695a22eSJohn Snow return match.group(0) 4764695a22eSJohn Snow 4774695a22eSJohn Snow 4784695a22eSJohn Snowdef diagnose( 4794695a22eSJohn Snow dep_spec: str, 4804695a22eSJohn Snow online: bool, 4814695a22eSJohn Snow wheels_dir: Optional[Union[str, Path]], 4824695a22eSJohn Snow prog: Optional[str], 4834695a22eSJohn Snow) -> Tuple[str, bool]: 4844695a22eSJohn Snow """ 4854695a22eSJohn Snow Offer a summary to the user as to why a package failed to be installed. 4864695a22eSJohn Snow 4874695a22eSJohn Snow :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5' 4884695a22eSJohn Snow :param online: Did we allow PyPI access? 4894695a22eSJohn Snow :param prog: 4904695a22eSJohn Snow Optionally, a shell program name that can be used as a 4914695a22eSJohn Snow bellwether to detect if this program is installed elsewhere on 4924695a22eSJohn Snow the system. This is used to offer advice when a program is 4934695a22eSJohn Snow detected for a different python version. 4944695a22eSJohn Snow :param wheels_dir: 4954695a22eSJohn Snow Optionally, a directory that was searched for vendored packages. 4964695a22eSJohn Snow """ 4974695a22eSJohn Snow # pylint: disable=too-many-branches 4984695a22eSJohn Snow 4994695a22eSJohn Snow # Some errors are not particularly serious 5004695a22eSJohn Snow bad = False 5014695a22eSJohn Snow 5024695a22eSJohn Snow pkg_name = pkgname_from_depspec(dep_spec) 5034695a22eSJohn Snow pkg_version = None 5044695a22eSJohn Snow 5054695a22eSJohn Snow has_importlib = False 5064695a22eSJohn Snow try: 5074695a22eSJohn Snow # Python 3.8+ stdlib 5084695a22eSJohn Snow # pylint: disable=import-outside-toplevel 5094695a22eSJohn Snow # pylint: disable=no-name-in-module 5104695a22eSJohn Snow # pylint: disable=import-error 5114695a22eSJohn Snow from importlib.metadata import ( # type: ignore 5124695a22eSJohn Snow PackageNotFoundError, 5134695a22eSJohn Snow version, 5144695a22eSJohn Snow ) 5154695a22eSJohn Snow 5164695a22eSJohn Snow has_importlib = True 5174695a22eSJohn Snow try: 5184695a22eSJohn Snow pkg_version = version(pkg_name) 5194695a22eSJohn Snow except PackageNotFoundError: 5204695a22eSJohn Snow pass 5214695a22eSJohn Snow except ModuleNotFoundError: 5224695a22eSJohn Snow pass 5234695a22eSJohn Snow 5244695a22eSJohn Snow lines = [] 5254695a22eSJohn Snow 5264695a22eSJohn Snow if pkg_version: 5274695a22eSJohn Snow lines.append( 5284695a22eSJohn Snow f"Python package '{pkg_name}' version '{pkg_version}' was found," 5294695a22eSJohn Snow " but isn't suitable." 5304695a22eSJohn Snow ) 5314695a22eSJohn Snow elif has_importlib: 5324695a22eSJohn Snow lines.append( 5334695a22eSJohn Snow f"Python package '{pkg_name}' was not found nor installed." 5344695a22eSJohn Snow ) 5354695a22eSJohn Snow else: 5364695a22eSJohn Snow lines.append( 5374695a22eSJohn Snow f"Python package '{pkg_name}' is either not found or" 5384695a22eSJohn Snow " not a suitable version." 5394695a22eSJohn Snow ) 5404695a22eSJohn Snow 5414695a22eSJohn Snow if wheels_dir: 5424695a22eSJohn Snow lines.append( 5434695a22eSJohn Snow "No suitable version found in, or failed to install from" 5444695a22eSJohn Snow f" '{wheels_dir}'." 5454695a22eSJohn Snow ) 5464695a22eSJohn Snow bad = True 5474695a22eSJohn Snow 5484695a22eSJohn Snow if online: 5494695a22eSJohn Snow lines.append("A suitable version could not be obtained from PyPI.") 5504695a22eSJohn Snow bad = True 5514695a22eSJohn Snow else: 5524695a22eSJohn Snow lines.append( 5534695a22eSJohn Snow "mkvenv was configured to operate offline and did not check PyPI." 5544695a22eSJohn Snow ) 5554695a22eSJohn Snow 5564695a22eSJohn Snow if prog and not pkg_version: 5574695a22eSJohn Snow which = shutil.which(prog) 5584695a22eSJohn Snow if which: 5594695a22eSJohn Snow if sys.base_prefix in site.PREFIXES: 5604695a22eSJohn Snow pypath = Path(sys.executable).resolve() 5614695a22eSJohn Snow lines.append( 5624695a22eSJohn Snow f"'{prog}' was detected on your system at '{which}', " 5634695a22eSJohn Snow f"but the Python package '{pkg_name}' was not found by " 5644695a22eSJohn Snow f"this Python interpreter ('{pypath}'). " 5654695a22eSJohn Snow f"Typically this means that '{prog}' has been installed " 5664695a22eSJohn Snow "against a different Python interpreter on your system." 5674695a22eSJohn Snow ) 5684695a22eSJohn Snow else: 5694695a22eSJohn Snow lines.append( 5704695a22eSJohn Snow f"'{prog}' was detected on your system at '{which}', " 5714695a22eSJohn Snow "but the build is using an isolated virtual environment." 5724695a22eSJohn Snow ) 5734695a22eSJohn Snow bad = True 5744695a22eSJohn Snow 5754695a22eSJohn Snow lines = [f" • {line}" for line in lines] 5764695a22eSJohn Snow if bad: 5774695a22eSJohn Snow lines.insert(0, f"Could not provide build dependency '{dep_spec}':") 5784695a22eSJohn Snow else: 5794695a22eSJohn Snow lines.insert(0, f"'{dep_spec}' not found:") 5804695a22eSJohn Snow return os.linesep.join(lines), bad 5814695a22eSJohn Snow 5824695a22eSJohn Snow 583c5538eedSJohn Snowdef pip_install( 584c5538eedSJohn Snow args: Sequence[str], 585c5538eedSJohn Snow online: bool = False, 586c5538eedSJohn Snow wheels_dir: Optional[Union[str, Path]] = None, 587c5538eedSJohn Snow) -> None: 588c5538eedSJohn Snow """ 589c5538eedSJohn Snow Use pip to install a package or package(s) as specified in @args. 590c5538eedSJohn Snow """ 591c5538eedSJohn Snow loud = bool( 592c5538eedSJohn Snow os.environ.get("DEBUG") 593c5538eedSJohn Snow or os.environ.get("GITLAB_CI") 594c5538eedSJohn Snow or os.environ.get("V") 595c5538eedSJohn Snow ) 596c5538eedSJohn Snow 597c5538eedSJohn Snow full_args = [ 598c5538eedSJohn Snow sys.executable, 599c5538eedSJohn Snow "-m", 600c5538eedSJohn Snow "pip", 601c5538eedSJohn Snow "install", 602c5538eedSJohn Snow "--disable-pip-version-check", 603c5538eedSJohn Snow "-v" if loud else "-q", 604c5538eedSJohn Snow ] 605c5538eedSJohn Snow if not online: 606c5538eedSJohn Snow full_args += ["--no-index"] 607c5538eedSJohn Snow if wheels_dir: 608c5538eedSJohn Snow full_args += ["--find-links", f"file://{str(wheels_dir)}"] 609c5538eedSJohn Snow full_args += list(args) 610c5538eedSJohn Snow subprocess.run( 611c5538eedSJohn Snow full_args, 612c5538eedSJohn Snow check=True, 613c5538eedSJohn Snow ) 614c5538eedSJohn Snow 615c5538eedSJohn Snow 6164695a22eSJohn Snowdef _do_ensure( 617c5538eedSJohn Snow dep_specs: Sequence[str], 618c5538eedSJohn Snow online: bool = False, 619c5538eedSJohn Snow wheels_dir: Optional[Union[str, Path]] = None, 620c5538eedSJohn Snow) -> None: 621c5538eedSJohn Snow """ 622c5538eedSJohn Snow Use pip to ensure we have the package specified by @dep_specs. 623c5538eedSJohn Snow 624c5538eedSJohn Snow If the package is already installed, do nothing. If online and 625c5538eedSJohn Snow wheels_dir are both provided, prefer packages found in wheels_dir 626c5538eedSJohn Snow first before connecting to PyPI. 627c5538eedSJohn Snow 628c5538eedSJohn Snow :param dep_specs: 629c5538eedSJohn Snow PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. 630c5538eedSJohn Snow :param online: If True, fall back to PyPI. 631c5538eedSJohn Snow :param wheels_dir: If specified, search this path for packages. 632c5538eedSJohn Snow """ 633c5538eedSJohn Snow with warnings.catch_warnings(): 634c5538eedSJohn Snow warnings.filterwarnings( 635c5538eedSJohn Snow "ignore", category=UserWarning, module="distlib" 636c5538eedSJohn Snow ) 637c5538eedSJohn Snow dist_path = distlib.database.DistributionPath(include_egg=True) 638c5538eedSJohn Snow absent = [] 63992834894SJohn Snow present = [] 640c5538eedSJohn Snow for spec in dep_specs: 641c5538eedSJohn Snow matcher = distlib.version.LegacyMatcher(spec) 642c5538eedSJohn Snow dist = dist_path.get_distribution(matcher.name) 643c5538eedSJohn Snow if dist is None or not matcher.match(dist.version): 644c5538eedSJohn Snow absent.append(spec) 645c5538eedSJohn Snow else: 646c5538eedSJohn Snow logger.info("found %s", dist) 64792834894SJohn Snow present.append(matcher.name) 64892834894SJohn Snow 64992834894SJohn Snow if present: 65092834894SJohn Snow generate_console_scripts(present) 651c5538eedSJohn Snow 652c5538eedSJohn Snow if absent: 653c5538eedSJohn Snow # Some packages are missing or aren't a suitable version, 654c5538eedSJohn Snow # install a suitable (possibly vendored) package. 655c5538eedSJohn Snow print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr) 656c5538eedSJohn Snow pip_install(args=absent, online=online, wheels_dir=wheels_dir) 657c5538eedSJohn Snow 658c5538eedSJohn Snow 6594695a22eSJohn Snowdef ensure( 6604695a22eSJohn Snow dep_specs: Sequence[str], 6614695a22eSJohn Snow online: bool = False, 6624695a22eSJohn Snow wheels_dir: Optional[Union[str, Path]] = None, 6634695a22eSJohn Snow prog: Optional[str] = None, 6644695a22eSJohn Snow) -> None: 6654695a22eSJohn Snow """ 6664695a22eSJohn Snow Use pip to ensure we have the package specified by @dep_specs. 6674695a22eSJohn Snow 6684695a22eSJohn Snow If the package is already installed, do nothing. If online and 6694695a22eSJohn Snow wheels_dir are both provided, prefer packages found in wheels_dir 6704695a22eSJohn Snow first before connecting to PyPI. 6714695a22eSJohn Snow 6724695a22eSJohn Snow :param dep_specs: 6734695a22eSJohn Snow PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. 6744695a22eSJohn Snow :param online: If True, fall back to PyPI. 6754695a22eSJohn Snow :param wheels_dir: If specified, search this path for packages. 6764695a22eSJohn Snow :param prog: 6774695a22eSJohn Snow If specified, use this program name for error diagnostics that will 6784695a22eSJohn Snow be presented to the user. e.g., 'sphinx-build' can be used as a 6794695a22eSJohn Snow bellwether for the presence of 'sphinx'. 6804695a22eSJohn Snow """ 6814695a22eSJohn Snow print(f"mkvenv: checking for {', '.join(dep_specs)}", file=sys.stderr) 682*68ea6d17SJohn Snow 683*68ea6d17SJohn Snow if not HAVE_DISTLIB: 684*68ea6d17SJohn Snow raise Ouch("a usable distlib could not be found, please install it") 685*68ea6d17SJohn Snow 6864695a22eSJohn Snow try: 6874695a22eSJohn Snow _do_ensure(dep_specs, online, wheels_dir) 6884695a22eSJohn Snow except subprocess.CalledProcessError as exc: 6894695a22eSJohn Snow # Well, that's not good. 6904695a22eSJohn Snow msg, bad = diagnose(dep_specs[0], online, wheels_dir, prog) 6914695a22eSJohn Snow if bad: 6924695a22eSJohn Snow raise Ouch(msg) from exc 6934695a22eSJohn Snow raise SystemExit(f"\n{msg}\n\n") from exc 6944695a22eSJohn Snow 6954695a22eSJohn Snow 696dd84028fSJohn Snowdef _add_create_subcommand(subparsers: Any) -> None: 697dd84028fSJohn Snow subparser = subparsers.add_parser("create", help="create a venv") 698dd84028fSJohn Snow subparser.add_argument( 699dd84028fSJohn Snow "target", 700dd84028fSJohn Snow type=str, 701dd84028fSJohn Snow action="store", 702dd84028fSJohn Snow help="Target directory to install virtual environment into.", 703dd84028fSJohn Snow ) 704dd84028fSJohn Snow 705dd84028fSJohn Snow 706c5538eedSJohn Snowdef _add_ensure_subcommand(subparsers: Any) -> None: 707c5538eedSJohn Snow subparser = subparsers.add_parser( 708c5538eedSJohn Snow "ensure", help="Ensure that the specified package is installed." 709c5538eedSJohn Snow ) 710c5538eedSJohn Snow subparser.add_argument( 711c5538eedSJohn Snow "--online", 712c5538eedSJohn Snow action="store_true", 713c5538eedSJohn Snow help="Install packages from PyPI, if necessary.", 714c5538eedSJohn Snow ) 715c5538eedSJohn Snow subparser.add_argument( 716c5538eedSJohn Snow "--dir", 717c5538eedSJohn Snow type=str, 718c5538eedSJohn Snow action="store", 719c5538eedSJohn Snow help="Path to vendored packages where we may install from.", 720c5538eedSJohn Snow ) 721c5538eedSJohn Snow subparser.add_argument( 7224695a22eSJohn Snow "--diagnose", 7234695a22eSJohn Snow type=str, 7244695a22eSJohn Snow action="store", 7254695a22eSJohn Snow help=( 7264695a22eSJohn Snow "Name of a shell utility to use for " 7274695a22eSJohn Snow "diagnostics if this command fails." 7284695a22eSJohn Snow ), 7294695a22eSJohn Snow ) 7304695a22eSJohn Snow subparser.add_argument( 731c5538eedSJohn Snow "dep_specs", 732c5538eedSJohn Snow type=str, 733c5538eedSJohn Snow action="store", 734c5538eedSJohn Snow help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'", 735c5538eedSJohn Snow nargs="+", 736c5538eedSJohn Snow ) 737c5538eedSJohn Snow 738c5538eedSJohn Snow 739dd84028fSJohn Snowdef main() -> int: 740dd84028fSJohn Snow """CLI interface to make_qemu_venv. See module docstring.""" 741dd84028fSJohn Snow if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"): 742dd84028fSJohn Snow # You're welcome. 743dd84028fSJohn Snow logging.basicConfig(level=logging.DEBUG) 744c5538eedSJohn Snow else: 745c5538eedSJohn Snow if os.environ.get("V"): 746dd84028fSJohn Snow logging.basicConfig(level=logging.INFO) 747dd84028fSJohn Snow 748c5538eedSJohn Snow # These are incredibly noisy even for V=1 749c5538eedSJohn Snow logging.getLogger("distlib.metadata").addFilter(lambda record: False) 750c5538eedSJohn Snow logging.getLogger("distlib.database").addFilter(lambda record: False) 751c5538eedSJohn Snow 752dd84028fSJohn Snow parser = argparse.ArgumentParser( 753dd84028fSJohn Snow prog="mkvenv", 754dd84028fSJohn Snow description="QEMU pyvenv bootstrapping utility", 755dd84028fSJohn Snow ) 756dd84028fSJohn Snow subparsers = parser.add_subparsers( 757dd84028fSJohn Snow title="Commands", 758dd84028fSJohn Snow dest="command", 759dd84028fSJohn Snow metavar="command", 760dd84028fSJohn Snow help="Description", 761dd84028fSJohn Snow ) 762dd84028fSJohn Snow 763dd84028fSJohn Snow _add_create_subcommand(subparsers) 764c5538eedSJohn Snow _add_ensure_subcommand(subparsers) 765dd84028fSJohn Snow 766dd84028fSJohn Snow args = parser.parse_args() 767dd84028fSJohn Snow try: 768dd84028fSJohn Snow if args.command == "create": 769dd84028fSJohn Snow make_venv( 770dd84028fSJohn Snow args.target, 771dd84028fSJohn Snow system_site_packages=True, 772dd84028fSJohn Snow clear=True, 773dd84028fSJohn Snow ) 774c5538eedSJohn Snow if args.command == "ensure": 775c5538eedSJohn Snow ensure( 776c5538eedSJohn Snow dep_specs=args.dep_specs, 777c5538eedSJohn Snow online=args.online, 778c5538eedSJohn Snow wheels_dir=args.dir, 7794695a22eSJohn Snow prog=args.diagnose, 780c5538eedSJohn Snow ) 781dd84028fSJohn Snow logger.debug("mkvenv.py %s: exiting", args.command) 782dd84028fSJohn Snow except Ouch as exc: 783dd84028fSJohn Snow print("\n*** Ouch! ***\n", file=sys.stderr) 784dd84028fSJohn Snow print(str(exc), "\n\n", file=sys.stderr) 785dd84028fSJohn Snow return 1 786dd84028fSJohn Snow except SystemExit: 787dd84028fSJohn Snow raise 788dd84028fSJohn Snow except: # pylint: disable=bare-except 789dd84028fSJohn Snow logger.exception("mkvenv did not complete successfully:") 790dd84028fSJohn Snow return 2 791dd84028fSJohn Snow return 0 792dd84028fSJohn Snow 793dd84028fSJohn Snow 794dd84028fSJohn Snowif __name__ == "__main__": 795dd84028fSJohn Snow sys.exit(main()) 796