1""" 2mkvenv - QEMU pyvenv bootstrapping utility 3 4usage: mkvenv [-h] command ... 5 6QEMU pyvenv bootstrapping utility 7 8options: 9 -h, --help show this help message and exit 10 11Commands: 12 command Description 13 create create a venv 14 post_init 15 post-venv initialization 16 ensure Ensure that the specified package is installed. 17 ensuregroup 18 Ensure that the specified package group is installed. 19 20-------------------------------------------------- 21 22usage: mkvenv create [-h] target 23 24positional arguments: 25 target Target directory to install virtual environment into. 26 27options: 28 -h, --help show this help message and exit 29 30-------------------------------------------------- 31 32usage: mkvenv post_init [-h] 33 34options: 35 -h, --help show this help message and exit 36 37-------------------------------------------------- 38 39usage: mkvenv ensure [-h] [--online] [--dir DIR] dep_spec... 40 41positional arguments: 42 dep_spec PEP 508 Dependency specification, e.g. 'meson>=0.61.5' 43 44options: 45 -h, --help show this help message and exit 46 --online Install packages from PyPI, if necessary. 47 --dir DIR Path to vendored packages where we may install from. 48 49-------------------------------------------------- 50 51usage: mkvenv ensuregroup [-h] [--online] [--dir DIR] file group... 52 53positional arguments: 54 file pointer to a TOML file 55 group section name in the TOML file 56 57options: 58 -h, --help show this help message and exit 59 --online Install packages from PyPI, if necessary. 60 --dir DIR Path to vendored packages where we may install from. 61 62""" 63 64# The duplication between importlib and pkg_resources does not help 65# pylint: disable=too-many-lines 66 67# Copyright (C) 2022-2023 Red Hat, Inc. 68# 69# Authors: 70# John Snow <jsnow@redhat.com> 71# Paolo Bonzini <pbonzini@redhat.com> 72# 73# This work is licensed under the terms of the GNU GPL, version 2 or 74# later. See the COPYING file in the top-level directory. 75 76import argparse 77from importlib.util import find_spec 78import logging 79import os 80from pathlib import Path 81import re 82import shutil 83import site 84import subprocess 85import sys 86import sysconfig 87from types import SimpleNamespace 88from typing import ( 89 Any, 90 Dict, 91 Iterator, 92 Optional, 93 Sequence, 94 Tuple, 95 Union, 96) 97import venv 98 99 100# Try to load distlib, with a fallback to pip's vendored version. 101# HAVE_DISTLIB is checked below, just-in-time, so that mkvenv does not fail 102# outside the venv or before a potential call to ensurepip in checkpip(). 103HAVE_DISTLIB = True 104try: 105 import distlib.scripts 106 import distlib.version 107except ImportError: 108 try: 109 # Reach into pip's cookie jar. pylint and flake8 don't understand 110 # that these imports will be used via distlib.xxx. 111 from pip._vendor import distlib 112 import pip._vendor.distlib.scripts # noqa, pylint: disable=unused-import 113 import pip._vendor.distlib.version # noqa, pylint: disable=unused-import 114 except ImportError: 115 HAVE_DISTLIB = False 116 117# Try to load tomllib, with a fallback to tomli. 118# HAVE_TOMLLIB is checked below, just-in-time, so that mkvenv does not fail 119# outside the venv or before a potential call to ensurepip in checkpip(). 120HAVE_TOMLLIB = True 121try: 122 import tomllib 123except ImportError: 124 try: 125 import tomli as tomllib 126 except ImportError: 127 HAVE_TOMLLIB = False 128 129# Do not add any mandatory dependencies from outside the stdlib: 130# This script *must* be usable standalone! 131 132DirType = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] 133logger = logging.getLogger("mkvenv") 134 135 136def inside_a_venv() -> bool: 137 """Returns True if it is executed inside of a virtual environment.""" 138 return sys.prefix != sys.base_prefix 139 140 141class Ouch(RuntimeError): 142 """An Exception class we can't confuse with a builtin.""" 143 144 145class QemuEnvBuilder(venv.EnvBuilder): 146 """ 147 An extension of venv.EnvBuilder for building QEMU's configure-time venv. 148 149 The primary difference is that it emulates a "nested" virtual 150 environment when invoked from inside of an existing virtual 151 environment by including packages from the parent. Also, 152 "ensurepip" is replaced if possible with just recreating pip's 153 console_scripts inside the virtual environment. 154 155 Parameters for base class init: 156 - system_site_packages: bool = False 157 - clear: bool = False 158 - symlinks: bool = False 159 - upgrade: bool = False 160 - with_pip: bool = False 161 - prompt: Optional[str] = None 162 - upgrade_deps: bool = False (Since 3.9) 163 """ 164 165 def __init__(self, *args: Any, **kwargs: Any) -> None: 166 logger.debug("QemuEnvBuilder.__init__(...)") 167 168 # For nested venv emulation: 169 self.use_parent_packages = False 170 if inside_a_venv(): 171 # Include parent packages only if we're in a venv and 172 # system_site_packages was True. 173 self.use_parent_packages = kwargs.pop( 174 "system_site_packages", False 175 ) 176 # Include system_site_packages only when the parent, 177 # The venv we are currently in, also does so. 178 kwargs["system_site_packages"] = sys.base_prefix in site.PREFIXES 179 180 # ensurepip is slow: venv creation can be very fast for cases where 181 # we allow the use of system_site_packages. Therefore, ensurepip is 182 # replaced with our own script generation once the virtual environment 183 # is setup. 184 self.want_pip = kwargs.get("with_pip", False) 185 if self.want_pip: 186 if ( 187 kwargs.get("system_site_packages", False) 188 and not need_ensurepip() 189 ): 190 kwargs["with_pip"] = False 191 else: 192 check_ensurepip(suggest_remedy=True) 193 194 super().__init__(*args, **kwargs) 195 196 # Make the context available post-creation: 197 self._context: Optional[SimpleNamespace] = None 198 199 def get_parent_libpath(self) -> Optional[str]: 200 """Return the libpath of the parent venv, if applicable.""" 201 if self.use_parent_packages: 202 return sysconfig.get_path("purelib") 203 return None 204 205 @staticmethod 206 def compute_venv_libpath(context: SimpleNamespace) -> str: 207 """ 208 Compatibility wrapper for context.lib_path for Python < 3.12 209 """ 210 # Python 3.12+, not strictly necessary because it's documented 211 # to be the same as 3.10 code below: 212 if sys.version_info >= (3, 12): 213 return context.lib_path 214 215 # Python 3.10+ 216 if "venv" in sysconfig.get_scheme_names(): 217 lib_path = sysconfig.get_path( 218 "purelib", scheme="venv", vars={"base": context.env_dir} 219 ) 220 assert lib_path is not None 221 return lib_path 222 223 # For Python <= 3.9 we need to hardcode this. Fortunately the 224 # code below was the same in Python 3.6-3.10, so there is only 225 # one case. 226 if sys.platform == "win32": 227 return os.path.join(context.env_dir, "Lib", "site-packages") 228 return os.path.join( 229 context.env_dir, 230 "lib", 231 "python%d.%d" % sys.version_info[:2], 232 "site-packages", 233 ) 234 235 def ensure_directories(self, env_dir: DirType) -> SimpleNamespace: 236 logger.debug("ensure_directories(env_dir=%s)", env_dir) 237 self._context = super().ensure_directories(env_dir) 238 return self._context 239 240 def create(self, env_dir: DirType) -> None: 241 logger.debug("create(env_dir=%s)", env_dir) 242 super().create(env_dir) 243 assert self._context is not None 244 self.post_post_setup(self._context) 245 246 def post_post_setup(self, context: SimpleNamespace) -> None: 247 """ 248 The final, final hook. Enter the venv and run commands inside of it. 249 """ 250 if self.use_parent_packages: 251 # We're inside of a venv and we want to include the parent 252 # venv's packages. 253 parent_libpath = self.get_parent_libpath() 254 assert parent_libpath is not None 255 logger.debug("parent_libpath: %s", parent_libpath) 256 257 our_libpath = self.compute_venv_libpath(context) 258 logger.debug("our_libpath: %s", our_libpath) 259 260 pth_file = os.path.join(our_libpath, "nested.pth") 261 with open(pth_file, "w", encoding="UTF-8") as file: 262 file.write(parent_libpath + os.linesep) 263 264 if self.want_pip: 265 args = [ 266 context.env_exe, 267 __file__, 268 "post_init", 269 ] 270 subprocess.run(args, check=True) 271 272 def get_value(self, field: str) -> str: 273 """ 274 Get a string value from the context namespace after a call to build. 275 276 For valid field names, see: 277 https://docs.python.org/3/library/venv.html#venv.EnvBuilder.ensure_directories 278 """ 279 ret = getattr(self._context, field) 280 assert isinstance(ret, str) 281 return ret 282 283 284def need_ensurepip() -> bool: 285 """ 286 Tests for the presence of setuptools and pip. 287 288 :return: `True` if we do not detect both packages. 289 """ 290 # Don't try to actually import them, it's fraught with danger: 291 # https://github.com/pypa/setuptools/issues/2993 292 if find_spec("setuptools") and find_spec("pip"): 293 return False 294 return True 295 296 297def check_ensurepip(prefix: str = "", suggest_remedy: bool = False) -> None: 298 """ 299 Check that we have ensurepip. 300 301 Raise a fatal exception with a helpful hint if it isn't available. 302 """ 303 if not find_spec("ensurepip"): 304 msg = ( 305 "Python's ensurepip module is not found.\n" 306 "It's normally part of the Python standard library, " 307 "maybe your distribution packages it separately?\n" 308 "(Debian puts ensurepip in its python3-venv package.)\n" 309 ) 310 if suggest_remedy: 311 msg += ( 312 "Either install ensurepip, or alleviate the need for it in the" 313 " first place by installing pip and setuptools for " 314 f"'{sys.executable}'.\n" 315 ) 316 raise Ouch(prefix + msg) 317 318 # ensurepip uses pyexpat, which can also go missing on us: 319 if not find_spec("pyexpat"): 320 msg = ( 321 "Python's pyexpat module is not found.\n" 322 "It's normally part of the Python standard library, " 323 "maybe your distribution packages it separately?\n" 324 "(NetBSD's pkgsrc debundles this to e.g. 'py310-expat'.)\n" 325 ) 326 if suggest_remedy: 327 msg += ( 328 "Either install pyexpat, or alleviate the need for it in the " 329 "first place by installing pip and setuptools for " 330 f"'{sys.executable}'.\n" 331 ) 332 raise Ouch(prefix + msg) 333 334 335def make_venv( # pylint: disable=too-many-arguments 336 env_dir: Union[str, Path], 337 system_site_packages: bool = False, 338 clear: bool = True, 339 symlinks: Optional[bool] = None, 340 with_pip: bool = True, 341) -> None: 342 """ 343 Create a venv using `QemuEnvBuilder`. 344 345 This is analogous to the `venv.create` module-level convenience 346 function that is part of the Python stdblib, except it uses 347 `QemuEnvBuilder` instead. 348 349 :param env_dir: The directory to create/install to. 350 :param system_site_packages: 351 Allow inheriting packages from the system installation. 352 :param clear: When True, fully remove any prior venv and files. 353 :param symlinks: 354 Whether to use symlinks to the target interpreter or not. If 355 left unspecified, it will use symlinks except on Windows to 356 match behavior with the "venv" CLI tool. 357 :param with_pip: 358 Whether to install "pip" binaries or not. 359 """ 360 logger.debug( 361 "%s: make_venv(env_dir=%s, system_site_packages=%s, " 362 "clear=%s, symlinks=%s, with_pip=%s)", 363 __file__, 364 str(env_dir), 365 system_site_packages, 366 clear, 367 symlinks, 368 with_pip, 369 ) 370 371 if symlinks is None: 372 # Default behavior of standard venv CLI 373 symlinks = os.name != "nt" 374 375 builder = QemuEnvBuilder( 376 system_site_packages=system_site_packages, 377 clear=clear, 378 symlinks=symlinks, 379 with_pip=with_pip, 380 ) 381 382 style = "non-isolated" if builder.system_site_packages else "isolated" 383 nested = "" 384 if builder.use_parent_packages: 385 nested = f"(with packages from '{builder.get_parent_libpath()}') " 386 print( 387 f"mkvenv: Creating {style} virtual environment" 388 f" {nested}at '{str(env_dir)}'", 389 file=sys.stderr, 390 ) 391 392 try: 393 logger.debug("Invoking builder.create()") 394 try: 395 builder.create(str(env_dir)) 396 except SystemExit as exc: 397 # Some versions of the venv module raise SystemExit; *nasty*! 398 # We want the exception that prompted it. It might be a subprocess 399 # error that has output we *really* want to see. 400 logger.debug("Intercepted SystemExit from EnvBuilder.create()") 401 raise exc.__cause__ or exc.__context__ or exc 402 logger.debug("builder.create() finished") 403 except subprocess.CalledProcessError as exc: 404 logger.error("mkvenv subprocess failed:") 405 logger.error("cmd: %s", exc.cmd) 406 logger.error("returncode: %d", exc.returncode) 407 408 def _stringify(data: Union[str, bytes]) -> str: 409 if isinstance(data, bytes): 410 return data.decode() 411 return data 412 413 lines = [] 414 if exc.stdout: 415 lines.append("========== stdout ==========") 416 lines.append(_stringify(exc.stdout)) 417 lines.append("============================") 418 if exc.stderr: 419 lines.append("========== stderr ==========") 420 lines.append(_stringify(exc.stderr)) 421 lines.append("============================") 422 if lines: 423 logger.error(os.linesep.join(lines)) 424 425 raise Ouch("VENV creation subprocess failed.") from exc 426 427 # print the python executable to stdout for configure. 428 print(builder.get_value("env_exe")) 429 430 431def _gen_importlib(packages: Sequence[str]) -> Iterator[str]: 432 # pylint: disable=import-outside-toplevel 433 # pylint: disable=no-name-in-module 434 # pylint: disable=import-error 435 try: 436 # First preference: Python 3.8+ stdlib 437 from importlib.metadata import ( # type: ignore 438 PackageNotFoundError, 439 distribution, 440 ) 441 except ImportError as exc: 442 logger.debug("%s", str(exc)) 443 # Second preference: Commonly available PyPI backport 444 from importlib_metadata import ( # type: ignore 445 PackageNotFoundError, 446 distribution, 447 ) 448 449 def _generator() -> Iterator[str]: 450 for package in packages: 451 try: 452 entry_points = distribution(package).entry_points 453 except PackageNotFoundError: 454 continue 455 456 # The EntryPoints type is only available in 3.10+, 457 # treat this as a vanilla list and filter it ourselves. 458 entry_points = filter( 459 lambda ep: ep.group == "console_scripts", entry_points 460 ) 461 462 for entry_point in entry_points: 463 yield f"{entry_point.name} = {entry_point.value}" 464 465 return _generator() 466 467 468def _gen_pkg_resources(packages: Sequence[str]) -> Iterator[str]: 469 # pylint: disable=import-outside-toplevel 470 # Bundled with setuptools; has a good chance of being available. 471 import pkg_resources 472 473 def _generator() -> Iterator[str]: 474 for package in packages: 475 try: 476 eps = pkg_resources.get_entry_map(package, "console_scripts") 477 except pkg_resources.DistributionNotFound: 478 continue 479 480 for entry_point in eps.values(): 481 yield str(entry_point) 482 483 return _generator() 484 485 486def generate_console_scripts( 487 packages: Sequence[str], 488 python_path: Optional[str] = None, 489 bin_path: Optional[str] = None, 490) -> None: 491 """ 492 Generate script shims for console_script entry points in @packages. 493 """ 494 if python_path is None: 495 python_path = sys.executable 496 if bin_path is None: 497 bin_path = sysconfig.get_path("scripts") 498 assert bin_path is not None 499 500 logger.debug( 501 "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)", 502 packages, 503 python_path, 504 bin_path, 505 ) 506 507 if not packages: 508 return 509 510 def _get_entry_points() -> Iterator[str]: 511 """Python 3.7 compatibility shim for iterating entry points.""" 512 # Python 3.8+, or Python 3.7 with importlib_metadata installed. 513 try: 514 return _gen_importlib(packages) 515 except ImportError as exc: 516 logger.debug("%s", str(exc)) 517 518 # Python 3.7 with setuptools installed. 519 try: 520 return _gen_pkg_resources(packages) 521 except ImportError as exc: 522 logger.debug("%s", str(exc)) 523 raise Ouch( 524 "Neither importlib.metadata nor pkg_resources found, " 525 "can't generate console script shims.\n" 526 "Use Python 3.8+, or install importlib-metadata or setuptools." 527 ) from exc 528 529 maker = distlib.scripts.ScriptMaker(None, bin_path) 530 maker.variants = {""} 531 maker.clobber = False 532 533 for entry_point in _get_entry_points(): 534 for filename in maker.make(entry_point): 535 logger.debug("wrote console_script '%s'", filename) 536 537 538def checkpip() -> bool: 539 """ 540 Debian10 has a pip that's broken when used inside of a virtual environment. 541 542 We try to detect and correct that case here. 543 """ 544 try: 545 # pylint: disable=import-outside-toplevel,unused-import,import-error 546 # pylint: disable=redefined-outer-name 547 import pip._internal # type: ignore # noqa: F401 548 549 logger.debug("pip appears to be working correctly.") 550 return False 551 except ModuleNotFoundError as exc: 552 if exc.name == "pip._internal": 553 # Uh, fair enough. They did say "internal". 554 # Let's just assume it's fine. 555 return False 556 logger.warning("pip appears to be malfunctioning: %s", str(exc)) 557 558 check_ensurepip("pip appears to be non-functional, and ") 559 560 logger.debug("Attempting to repair pip ...") 561 subprocess.run( 562 (sys.executable, "-m", "ensurepip"), 563 stdout=subprocess.DEVNULL, 564 check=True, 565 ) 566 logger.debug("Pip is now (hopefully) repaired!") 567 return True 568 569 570def pkgname_from_depspec(dep_spec: str) -> str: 571 """ 572 Parse package name out of a PEP-508 depspec. 573 574 See https://peps.python.org/pep-0508/#names 575 """ 576 match = re.match( 577 r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE 578 ) 579 if not match: 580 raise ValueError( 581 f"dep_spec '{dep_spec}'" 582 " does not appear to contain a valid package name" 583 ) 584 return match.group(0) 585 586 587def _get_path_importlib(package: str) -> Optional[str]: 588 # pylint: disable=import-outside-toplevel 589 # pylint: disable=no-name-in-module 590 # pylint: disable=import-error 591 try: 592 # First preference: Python 3.8+ stdlib 593 from importlib.metadata import ( # type: ignore 594 PackageNotFoundError, 595 distribution, 596 ) 597 except ImportError as exc: 598 logger.debug("%s", str(exc)) 599 # Second preference: Commonly available PyPI backport 600 from importlib_metadata import ( # type: ignore 601 PackageNotFoundError, 602 distribution, 603 ) 604 605 try: 606 return str(distribution(package).locate_file(".")) 607 except PackageNotFoundError: 608 return None 609 610 611def _get_path_pkg_resources(package: str) -> Optional[str]: 612 # pylint: disable=import-outside-toplevel 613 # Bundled with setuptools; has a good chance of being available. 614 import pkg_resources 615 616 try: 617 return str(pkg_resources.get_distribution(package).location) 618 except pkg_resources.DistributionNotFound: 619 return None 620 621 622def _get_path(package: str) -> Optional[str]: 623 try: 624 return _get_path_importlib(package) 625 except ImportError as exc: 626 logger.debug("%s", str(exc)) 627 628 try: 629 return _get_path_pkg_resources(package) 630 except ImportError as exc: 631 logger.debug("%s", str(exc)) 632 raise Ouch( 633 "Neither importlib.metadata nor pkg_resources found. " 634 "Use Python 3.8+, or install importlib-metadata or setuptools." 635 ) from exc 636 637 638def _path_is_prefix(prefix: Optional[str], path: str) -> bool: 639 try: 640 return ( 641 prefix is not None and os.path.commonpath([prefix, path]) == prefix 642 ) 643 except ValueError: 644 return False 645 646 647def _is_system_package(package: str) -> bool: 648 path = _get_path(package) 649 return path is not None and not ( 650 _path_is_prefix(sysconfig.get_path("purelib"), path) 651 or _path_is_prefix(sysconfig.get_path("platlib"), path) 652 ) 653 654 655def _get_version_importlib(package: str) -> Optional[str]: 656 # pylint: disable=import-outside-toplevel 657 # pylint: disable=no-name-in-module 658 # pylint: disable=import-error 659 try: 660 # First preference: Python 3.8+ stdlib 661 from importlib.metadata import ( # type: ignore 662 PackageNotFoundError, 663 distribution, 664 ) 665 except ImportError as exc: 666 logger.debug("%s", str(exc)) 667 # Second preference: Commonly available PyPI backport 668 from importlib_metadata import ( # type: ignore 669 PackageNotFoundError, 670 distribution, 671 ) 672 673 try: 674 return str(distribution(package).version) 675 except PackageNotFoundError: 676 return None 677 678 679def _get_version_pkg_resources(package: str) -> Optional[str]: 680 # pylint: disable=import-outside-toplevel 681 # Bundled with setuptools; has a good chance of being available. 682 import pkg_resources 683 684 try: 685 return str(pkg_resources.get_distribution(package).version) 686 except pkg_resources.DistributionNotFound: 687 return None 688 689 690def _get_version(package: str) -> Optional[str]: 691 try: 692 return _get_version_importlib(package) 693 except ImportError as exc: 694 logger.debug("%s", str(exc)) 695 696 try: 697 return _get_version_pkg_resources(package) 698 except ImportError as exc: 699 logger.debug("%s", str(exc)) 700 raise Ouch( 701 "Neither importlib.metadata nor pkg_resources found. " 702 "Use Python 3.8+, or install importlib-metadata or setuptools." 703 ) from exc 704 705 706def diagnose( 707 dep_spec: str, 708 online: bool, 709 wheels_dir: Optional[Union[str, Path]], 710 prog: Optional[str], 711) -> Tuple[str, bool]: 712 """ 713 Offer a summary to the user as to why a package failed to be installed. 714 715 :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5' 716 :param online: Did we allow PyPI access? 717 :param prog: 718 Optionally, a shell program name that can be used as a 719 bellwether to detect if this program is installed elsewhere on 720 the system. This is used to offer advice when a program is 721 detected for a different python version. 722 :param wheels_dir: 723 Optionally, a directory that was searched for vendored packages. 724 """ 725 # pylint: disable=too-many-branches 726 727 # Some errors are not particularly serious 728 bad = False 729 730 pkg_name = pkgname_from_depspec(dep_spec) 731 pkg_version = _get_version(pkg_name) 732 733 lines = [] 734 735 if pkg_version: 736 lines.append( 737 f"Python package '{pkg_name}' version '{pkg_version}' was found," 738 " but isn't suitable." 739 ) 740 else: 741 lines.append( 742 f"Python package '{pkg_name}' was not found nor installed." 743 ) 744 745 if wheels_dir: 746 lines.append( 747 "No suitable version found in, or failed to install from" 748 f" '{wheels_dir}'." 749 ) 750 bad = True 751 752 if online: 753 lines.append("A suitable version could not be obtained from PyPI.") 754 bad = True 755 else: 756 lines.append( 757 "mkvenv was configured to operate offline and did not check PyPI." 758 ) 759 760 if prog and not pkg_version: 761 which = shutil.which(prog) 762 if which: 763 if sys.base_prefix in site.PREFIXES: 764 pypath = Path(sys.executable).resolve() 765 lines.append( 766 f"'{prog}' was detected on your system at '{which}', " 767 f"but the Python package '{pkg_name}' was not found by " 768 f"this Python interpreter ('{pypath}'). " 769 f"Typically this means that '{prog}' has been installed " 770 "against a different Python interpreter on your system." 771 ) 772 else: 773 lines.append( 774 f"'{prog}' was detected on your system at '{which}', " 775 "but the build is using an isolated virtual environment." 776 ) 777 bad = True 778 779 lines = [f" • {line}" for line in lines] 780 if bad: 781 lines.insert(0, f"Could not provide build dependency '{dep_spec}':") 782 else: 783 lines.insert(0, f"'{dep_spec}' not found:") 784 return os.linesep.join(lines), bad 785 786 787def pip_install( 788 args: Sequence[str], 789 online: bool = False, 790 wheels_dir: Optional[Union[str, Path]] = None, 791) -> None: 792 """ 793 Use pip to install a package or package(s) as specified in @args. 794 """ 795 loud = bool( 796 os.environ.get("DEBUG") 797 or os.environ.get("GITLAB_CI") 798 or os.environ.get("V") 799 ) 800 801 full_args = [ 802 sys.executable, 803 "-m", 804 "pip", 805 "install", 806 "--disable-pip-version-check", 807 "-v" if loud else "-q", 808 ] 809 if not online: 810 full_args += ["--no-index"] 811 if wheels_dir: 812 full_args += ["--find-links", f"file://{str(wheels_dir)}"] 813 full_args += list(args) 814 subprocess.run( 815 full_args, 816 check=True, 817 ) 818 819 820def _make_version_constraint(info: Dict[str, str], install: bool) -> str: 821 """ 822 Construct the version constraint part of a PEP 508 dependency 823 specification (for example '>=0.61.5') from the accepted and 824 installed keys of the provided dictionary. 825 826 :param info: A dictionary corresponding to a TOML key-value list. 827 :param install: True generates install constraints, False generates 828 presence constraints 829 """ 830 if install and "installed" in info: 831 return "==" + info["installed"] 832 833 dep_spec = info.get("accepted", "") 834 dep_spec = dep_spec.strip() 835 # Double check that they didn't just use a version number 836 if dep_spec and dep_spec[0] not in "!~><=(": 837 raise Ouch( 838 "invalid dependency specifier " + dep_spec + " in dependency file" 839 ) 840 841 return dep_spec 842 843 844def _do_ensure( 845 group: Dict[str, Dict[str, str]], 846 online: bool = False, 847 wheels_dir: Optional[Union[str, Path]] = None, 848) -> Optional[Tuple[str, bool]]: 849 """ 850 Use pip to ensure we have the packages specified in @group. 851 852 If the packages are already installed, do nothing. If online and 853 wheels_dir are both provided, prefer packages found in wheels_dir 854 first before connecting to PyPI. 855 856 :param group: A dictionary of dictionaries, corresponding to a 857 section in a pythondeps.toml file. 858 :param online: If True, fall back to PyPI. 859 :param wheels_dir: If specified, search this path for packages. 860 """ 861 absent = [] 862 present = [] 863 canary = None 864 for name, info in group.items(): 865 constraint = _make_version_constraint(info, False) 866 matcher = distlib.version.LegacyMatcher(name + constraint) 867 print(f"mkvenv: checking for {matcher}", file=sys.stderr) 868 ver = _get_version(name) 869 if ( 870 ver is None 871 # Always pass installed package to pip, so that they can be 872 # updated if the requested version changes 873 or not _is_system_package(name) 874 or not matcher.match(distlib.version.LegacyVersion(ver)) 875 ): 876 absent.append(name + _make_version_constraint(info, True)) 877 if len(absent) == 1: 878 canary = info.get("canary", None) 879 else: 880 logger.info("found %s %s", name, ver) 881 present.append(name) 882 883 if present: 884 generate_console_scripts(present) 885 886 if absent: 887 if online or wheels_dir: 888 # Some packages are missing or aren't a suitable version, 889 # install a suitable (possibly vendored) package. 890 print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr) 891 try: 892 pip_install(args=absent, online=online, wheels_dir=wheels_dir) 893 return None 894 except subprocess.CalledProcessError: 895 pass 896 897 return diagnose( 898 absent[0], 899 online, 900 wheels_dir, 901 canary, 902 ) 903 904 return None 905 906 907def ensure( 908 dep_specs: Sequence[str], 909 online: bool = False, 910 wheels_dir: Optional[Union[str, Path]] = None, 911 prog: Optional[str] = None, 912) -> None: 913 """ 914 Use pip to ensure we have the package specified by @dep_specs. 915 916 If the package is already installed, do nothing. If online and 917 wheels_dir are both provided, prefer packages found in wheels_dir 918 first before connecting to PyPI. 919 920 :param dep_specs: 921 PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. 922 :param online: If True, fall back to PyPI. 923 :param wheels_dir: If specified, search this path for packages. 924 :param prog: 925 If specified, use this program name for error diagnostics that will 926 be presented to the user. e.g., 'sphinx-build' can be used as a 927 bellwether for the presence of 'sphinx'. 928 """ 929 930 if not HAVE_DISTLIB: 931 raise Ouch("a usable distlib could not be found, please install it") 932 933 # Convert the depspecs to a dictionary, as if they came 934 # from a section in a pythondeps.toml file 935 group: Dict[str, Dict[str, str]] = {} 936 for spec in dep_specs: 937 name = distlib.version.LegacyMatcher(spec).name 938 group[name] = {} 939 940 spec = spec.strip() 941 pos = len(name) 942 ver = spec[pos:].strip() 943 if ver: 944 group[name]["accepted"] = ver 945 946 if prog: 947 group[name]["canary"] = prog 948 prog = None 949 950 result = _do_ensure(group, online, wheels_dir) 951 if result: 952 # Well, that's not good. 953 if result[1]: 954 raise Ouch(result[0]) 955 raise SystemExit(f"\n{result[0]}\n\n") 956 957 958def _parse_groups(file: str) -> Dict[str, Dict[str, Any]]: 959 if not HAVE_TOMLLIB: 960 if sys.version_info < (3, 11): 961 raise Ouch("found no usable tomli, please install it") 962 963 raise Ouch( 964 "Python >=3.11 does not have tomllib... what have you done!?" 965 ) 966 967 # Use loads() to support both tomli v1.2.x (Ubuntu 22.04, 968 # Debian bullseye-backports) and v2.0.x 969 with open(file, "r", encoding="ascii") as depfile: 970 contents = depfile.read() 971 return tomllib.loads(contents) # type: ignore 972 973 974def ensure_group( 975 file: str, 976 groups: Sequence[str], 977 online: bool = False, 978 wheels_dir: Optional[Union[str, Path]] = None, 979) -> None: 980 """ 981 Use pip to ensure we have the package specified by @dep_specs. 982 983 If the package is already installed, do nothing. If online and 984 wheels_dir are both provided, prefer packages found in wheels_dir 985 first before connecting to PyPI. 986 987 :param dep_specs: 988 PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. 989 :param online: If True, fall back to PyPI. 990 :param wheels_dir: If specified, search this path for packages. 991 """ 992 993 if not HAVE_DISTLIB: 994 raise Ouch("found no usable distlib, please install it") 995 996 parsed_deps = _parse_groups(file) 997 998 to_install: Dict[str, Dict[str, str]] = {} 999 for group in groups: 1000 try: 1001 to_install.update(parsed_deps[group]) 1002 except KeyError as exc: 1003 raise Ouch(f"group {group} not defined") from exc 1004 1005 result = _do_ensure(to_install, online, wheels_dir) 1006 if result: 1007 # Well, that's not good. 1008 if result[1]: 1009 raise Ouch(result[0]) 1010 raise SystemExit(f"\n{result[0]}\n\n") 1011 1012 1013def post_venv_setup() -> None: 1014 """ 1015 This is intended to be run *inside the venv* after it is created. 1016 """ 1017 logger.debug("post_venv_setup()") 1018 # Test for a broken pip (Debian 10 or derivative?) and fix it if needed 1019 if not checkpip(): 1020 # Finally, generate a 'pip' script so the venv is usable in a normal 1021 # way from the CLI. This only happens when we inherited pip from a 1022 # parent/system-site and haven't run ensurepip in some way. 1023 generate_console_scripts(["pip"]) 1024 1025 1026def _add_create_subcommand(subparsers: Any) -> None: 1027 subparser = subparsers.add_parser("create", help="create a venv") 1028 subparser.add_argument( 1029 "target", 1030 type=str, 1031 action="store", 1032 help="Target directory to install virtual environment into.", 1033 ) 1034 1035 1036def _add_post_init_subcommand(subparsers: Any) -> None: 1037 subparsers.add_parser("post_init", help="post-venv initialization") 1038 1039 1040def _add_ensuregroup_subcommand(subparsers: Any) -> None: 1041 subparser = subparsers.add_parser( 1042 "ensuregroup", 1043 help="Ensure that the specified package group is installed.", 1044 ) 1045 subparser.add_argument( 1046 "--online", 1047 action="store_true", 1048 help="Install packages from PyPI, if necessary.", 1049 ) 1050 subparser.add_argument( 1051 "--dir", 1052 type=str, 1053 action="store", 1054 help="Path to vendored packages where we may install from.", 1055 ) 1056 subparser.add_argument( 1057 "file", 1058 type=str, 1059 action="store", 1060 help=("Path to a TOML file describing package groups"), 1061 ) 1062 subparser.add_argument( 1063 "group", 1064 type=str, 1065 action="store", 1066 help="One or more package group names", 1067 nargs="+", 1068 ) 1069 1070 1071def _add_ensure_subcommand(subparsers: Any) -> None: 1072 subparser = subparsers.add_parser( 1073 "ensure", help="Ensure that the specified package is installed." 1074 ) 1075 subparser.add_argument( 1076 "--online", 1077 action="store_true", 1078 help="Install packages from PyPI, if necessary.", 1079 ) 1080 subparser.add_argument( 1081 "--dir", 1082 type=str, 1083 action="store", 1084 help="Path to vendored packages where we may install from.", 1085 ) 1086 subparser.add_argument( 1087 "--diagnose", 1088 type=str, 1089 action="store", 1090 help=( 1091 "Name of a shell utility to use for " 1092 "diagnostics if this command fails." 1093 ), 1094 ) 1095 subparser.add_argument( 1096 "dep_specs", 1097 type=str, 1098 action="store", 1099 help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'", 1100 nargs="+", 1101 ) 1102 1103 1104def main() -> int: 1105 """CLI interface to make_qemu_venv. See module docstring.""" 1106 if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"): 1107 # You're welcome. 1108 logging.basicConfig(level=logging.DEBUG) 1109 else: 1110 if os.environ.get("V"): 1111 logging.basicConfig(level=logging.INFO) 1112 1113 parser = argparse.ArgumentParser( 1114 prog="mkvenv", 1115 description="QEMU pyvenv bootstrapping utility", 1116 ) 1117 subparsers = parser.add_subparsers( 1118 title="Commands", 1119 dest="command", 1120 required=True, 1121 metavar="command", 1122 help="Description", 1123 ) 1124 1125 _add_create_subcommand(subparsers) 1126 _add_post_init_subcommand(subparsers) 1127 _add_ensure_subcommand(subparsers) 1128 _add_ensuregroup_subcommand(subparsers) 1129 1130 args = parser.parse_args() 1131 try: 1132 if args.command == "create": 1133 make_venv( 1134 args.target, 1135 system_site_packages=True, 1136 clear=True, 1137 ) 1138 if args.command == "post_init": 1139 post_venv_setup() 1140 if args.command == "ensure": 1141 ensure( 1142 dep_specs=args.dep_specs, 1143 online=args.online, 1144 wheels_dir=args.dir, 1145 prog=args.diagnose, 1146 ) 1147 if args.command == "ensuregroup": 1148 ensure_group( 1149 file=args.file, 1150 groups=args.group, 1151 online=args.online, 1152 wheels_dir=args.dir, 1153 ) 1154 logger.debug("mkvenv.py %s: exiting", args.command) 1155 except Ouch as exc: 1156 print("\n*** Ouch! ***\n", file=sys.stderr) 1157 print(str(exc), "\n\n", file=sys.stderr) 1158 return 1 1159 except SystemExit: 1160 raise 1161 except: # pylint: disable=bare-except 1162 logger.exception("mkvenv did not complete successfully:") 1163 return 2 1164 return 0 1165 1166 1167if __name__ == "__main__": 1168 sys.exit(main()) 1169