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 EntryPoint, 439 PackageNotFoundError, 440 distribution, 441 ) 442 except ImportError as exc: 443 logger.debug("%s", str(exc)) 444 # Second preference: Commonly available PyPI backport 445 from importlib_metadata import ( # type: ignore 446 EntryPoint, 447 PackageNotFoundError, 448 distribution, 449 ) 450 451 def _generator() -> Iterator[str]: 452 for package in packages: 453 try: 454 entry_points: Iterator[EntryPoint] = \ 455 iter(distribution(package).entry_points) 456 except PackageNotFoundError: 457 continue 458 459 # The EntryPoints type is only available in 3.10+, 460 # treat this as a vanilla list and filter it ourselves. 461 entry_points = filter( 462 lambda ep: ep.group == "console_scripts", entry_points 463 ) 464 465 for entry_point in entry_points: 466 yield f"{entry_point.name} = {entry_point.value}" 467 468 return _generator() 469 470 471def _gen_pkg_resources(packages: Sequence[str]) -> Iterator[str]: 472 # pylint: disable=import-outside-toplevel 473 # Bundled with setuptools; has a good chance of being available. 474 import pkg_resources 475 476 def _generator() -> Iterator[str]: 477 for package in packages: 478 try: 479 eps = pkg_resources.get_entry_map(package, "console_scripts") 480 except pkg_resources.DistributionNotFound: 481 continue 482 483 for entry_point in eps.values(): 484 yield str(entry_point) 485 486 return _generator() 487 488 489def generate_console_scripts( 490 packages: Sequence[str], 491 python_path: Optional[str] = None, 492 bin_path: Optional[str] = None, 493) -> None: 494 """ 495 Generate script shims for console_script entry points in @packages. 496 """ 497 if python_path is None: 498 python_path = sys.executable 499 if bin_path is None: 500 bin_path = sysconfig.get_path("scripts") 501 assert bin_path is not None 502 503 logger.debug( 504 "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)", 505 packages, 506 python_path, 507 bin_path, 508 ) 509 510 if not packages: 511 return 512 513 def _get_entry_points() -> Iterator[str]: 514 """Python 3.7 compatibility shim for iterating entry points.""" 515 # Python 3.8+, or Python 3.7 with importlib_metadata installed. 516 try: 517 return _gen_importlib(packages) 518 except ImportError as exc: 519 logger.debug("%s", str(exc)) 520 521 # Python 3.7 with setuptools installed. 522 try: 523 return _gen_pkg_resources(packages) 524 except ImportError as exc: 525 logger.debug("%s", str(exc)) 526 raise Ouch( 527 "Neither importlib.metadata nor pkg_resources found, " 528 "can't generate console script shims.\n" 529 "Use Python 3.8+, or install importlib-metadata or setuptools." 530 ) from exc 531 532 maker = distlib.scripts.ScriptMaker(None, bin_path) 533 maker.variants = {""} 534 maker.clobber = False 535 536 for entry_point in _get_entry_points(): 537 for filename in maker.make(entry_point): 538 logger.debug("wrote console_script '%s'", filename) 539 540 541def checkpip() -> bool: 542 """ 543 Debian10 has a pip that's broken when used inside of a virtual environment. 544 545 We try to detect and correct that case here. 546 """ 547 try: 548 # pylint: disable=import-outside-toplevel,unused-import,import-error 549 # pylint: disable=redefined-outer-name 550 import pip._internal # type: ignore # noqa: F401 551 552 logger.debug("pip appears to be working correctly.") 553 return False 554 except ModuleNotFoundError as exc: 555 if exc.name == "pip._internal": 556 # Uh, fair enough. They did say "internal". 557 # Let's just assume it's fine. 558 return False 559 logger.warning("pip appears to be malfunctioning: %s", str(exc)) 560 561 check_ensurepip("pip appears to be non-functional, and ") 562 563 logger.debug("Attempting to repair pip ...") 564 subprocess.run( 565 (sys.executable, "-m", "ensurepip"), 566 stdout=subprocess.DEVNULL, 567 check=True, 568 ) 569 logger.debug("Pip is now (hopefully) repaired!") 570 return True 571 572 573def pkgname_from_depspec(dep_spec: str) -> str: 574 """ 575 Parse package name out of a PEP-508 depspec. 576 577 See https://peps.python.org/pep-0508/#names 578 """ 579 match = re.match( 580 r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE 581 ) 582 if not match: 583 raise ValueError( 584 f"dep_spec '{dep_spec}'" 585 " does not appear to contain a valid package name" 586 ) 587 return match.group(0) 588 589 590def _get_path_importlib(package: str) -> Optional[str]: 591 # pylint: disable=import-outside-toplevel 592 # pylint: disable=no-name-in-module 593 # pylint: disable=import-error 594 try: 595 # First preference: Python 3.8+ stdlib 596 from importlib.metadata import ( # type: ignore 597 PackageNotFoundError, 598 distribution, 599 ) 600 except ImportError as exc: 601 logger.debug("%s", str(exc)) 602 # Second preference: Commonly available PyPI backport 603 from importlib_metadata import ( # type: ignore 604 PackageNotFoundError, 605 distribution, 606 ) 607 608 try: 609 return str(distribution(package).locate_file(".")) 610 except PackageNotFoundError: 611 return None 612 613 614def _get_path_pkg_resources(package: str) -> Optional[str]: 615 # pylint: disable=import-outside-toplevel 616 # Bundled with setuptools; has a good chance of being available. 617 import pkg_resources 618 619 try: 620 return str(pkg_resources.get_distribution(package).location) 621 except pkg_resources.DistributionNotFound: 622 return None 623 624 625def _get_path(package: str) -> Optional[str]: 626 try: 627 return _get_path_importlib(package) 628 except ImportError as exc: 629 logger.debug("%s", str(exc)) 630 631 try: 632 return _get_path_pkg_resources(package) 633 except ImportError as exc: 634 logger.debug("%s", str(exc)) 635 raise Ouch( 636 "Neither importlib.metadata nor pkg_resources found. " 637 "Use Python 3.8+, or install importlib-metadata or setuptools." 638 ) from exc 639 640 641def _path_is_prefix(prefix: Optional[str], path: str) -> bool: 642 try: 643 return ( 644 prefix is not None and os.path.commonpath([prefix, path]) == prefix 645 ) 646 except ValueError: 647 return False 648 649 650def _is_system_package(package: str) -> bool: 651 path = _get_path(package) 652 return path is not None and not ( 653 _path_is_prefix(sysconfig.get_path("purelib"), path) 654 or _path_is_prefix(sysconfig.get_path("platlib"), path) 655 ) 656 657 658def _get_version_importlib(package: str) -> Optional[str]: 659 # pylint: disable=import-outside-toplevel 660 # pylint: disable=no-name-in-module 661 # pylint: disable=import-error 662 try: 663 # First preference: Python 3.8+ stdlib 664 from importlib.metadata import ( # type: ignore 665 PackageNotFoundError, 666 distribution, 667 ) 668 except ImportError as exc: 669 logger.debug("%s", str(exc)) 670 # Second preference: Commonly available PyPI backport 671 from importlib_metadata import ( # type: ignore 672 PackageNotFoundError, 673 distribution, 674 ) 675 676 try: 677 return str(distribution(package).version) 678 except PackageNotFoundError: 679 return None 680 681 682def _get_version_pkg_resources(package: str) -> Optional[str]: 683 # pylint: disable=import-outside-toplevel 684 # Bundled with setuptools; has a good chance of being available. 685 import pkg_resources 686 687 try: 688 return str(pkg_resources.get_distribution(package).version) 689 except pkg_resources.DistributionNotFound: 690 return None 691 692 693def _get_version(package: str) -> Optional[str]: 694 try: 695 return _get_version_importlib(package) 696 except ImportError as exc: 697 logger.debug("%s", str(exc)) 698 699 try: 700 return _get_version_pkg_resources(package) 701 except ImportError as exc: 702 logger.debug("%s", str(exc)) 703 raise Ouch( 704 "Neither importlib.metadata nor pkg_resources found. " 705 "Use Python 3.8+, or install importlib-metadata or setuptools." 706 ) from exc 707 708 709def diagnose( 710 dep_spec: str, 711 online: bool, 712 wheels_dir: Optional[Union[str, Path]], 713 prog: Optional[str], 714) -> Tuple[str, bool]: 715 """ 716 Offer a summary to the user as to why a package failed to be installed. 717 718 :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5' 719 :param online: Did we allow PyPI access? 720 :param prog: 721 Optionally, a shell program name that can be used as a 722 bellwether to detect if this program is installed elsewhere on 723 the system. This is used to offer advice when a program is 724 detected for a different python version. 725 :param wheels_dir: 726 Optionally, a directory that was searched for vendored packages. 727 """ 728 # pylint: disable=too-many-branches 729 730 # Some errors are not particularly serious 731 bad = False 732 733 pkg_name = pkgname_from_depspec(dep_spec) 734 pkg_version = _get_version(pkg_name) 735 736 lines = [] 737 738 if pkg_version: 739 lines.append( 740 f"Python package '{pkg_name}' version '{pkg_version}' was found," 741 " but isn't suitable." 742 ) 743 else: 744 lines.append( 745 f"Python package '{pkg_name}' was not found nor installed." 746 ) 747 748 if wheels_dir: 749 lines.append( 750 "No suitable version found in, or failed to install from" 751 f" '{wheels_dir}'." 752 ) 753 bad = True 754 755 if online: 756 lines.append("A suitable version could not be obtained from PyPI.") 757 bad = True 758 else: 759 lines.append( 760 "mkvenv was configured to operate offline and did not check PyPI." 761 ) 762 763 if prog and not pkg_version: 764 which = shutil.which(prog) 765 if which: 766 if sys.base_prefix in site.PREFIXES: 767 pypath = Path(sys.executable).resolve() 768 lines.append( 769 f"'{prog}' was detected on your system at '{which}', " 770 f"but the Python package '{pkg_name}' was not found by " 771 f"this Python interpreter ('{pypath}'). " 772 f"Typically this means that '{prog}' has been installed " 773 "against a different Python interpreter on your system." 774 ) 775 else: 776 lines.append( 777 f"'{prog}' was detected on your system at '{which}', " 778 "but the build is using an isolated virtual environment." 779 ) 780 bad = True 781 782 lines = [f" • {line}" for line in lines] 783 if bad: 784 lines.insert(0, f"Could not provide build dependency '{dep_spec}':") 785 else: 786 lines.insert(0, f"'{dep_spec}' not found:") 787 return os.linesep.join(lines), bad 788 789 790def pip_install( 791 args: Sequence[str], 792 online: bool = False, 793 wheels_dir: Optional[Union[str, Path]] = None, 794) -> None: 795 """ 796 Use pip to install a package or package(s) as specified in @args. 797 """ 798 loud = bool( 799 os.environ.get("DEBUG") 800 or os.environ.get("GITLAB_CI") 801 or os.environ.get("V") 802 ) 803 804 full_args = [ 805 sys.executable, 806 "-m", 807 "pip", 808 "install", 809 "--disable-pip-version-check", 810 "-v" if loud else "-q", 811 ] 812 if not online: 813 full_args += ["--no-index"] 814 if wheels_dir: 815 full_args += ["--find-links", f"file://{str(wheels_dir)}"] 816 full_args += list(args) 817 subprocess.run( 818 full_args, 819 check=True, 820 ) 821 822 823def _make_version_constraint(info: Dict[str, str], install: bool) -> str: 824 """ 825 Construct the version constraint part of a PEP 508 dependency 826 specification (for example '>=0.61.5') from the accepted and 827 installed keys of the provided dictionary. 828 829 :param info: A dictionary corresponding to a TOML key-value list. 830 :param install: True generates install constraints, False generates 831 presence constraints 832 """ 833 if install and "installed" in info: 834 return "==" + info["installed"] 835 836 dep_spec = info.get("accepted", "") 837 dep_spec = dep_spec.strip() 838 # Double check that they didn't just use a version number 839 if dep_spec and dep_spec[0] not in "!~><=(": 840 raise Ouch( 841 "invalid dependency specifier " + dep_spec + " in dependency file" 842 ) 843 844 return dep_spec 845 846 847def _do_ensure( 848 group: Dict[str, Dict[str, str]], 849 online: bool = False, 850 wheels_dir: Optional[Union[str, Path]] = None, 851) -> Optional[Tuple[str, bool]]: 852 """ 853 Use pip to ensure we have the packages specified in @group. 854 855 If the packages are already installed, do nothing. If online and 856 wheels_dir are both provided, prefer packages found in wheels_dir 857 first before connecting to PyPI. 858 859 :param group: A dictionary of dictionaries, corresponding to a 860 section in a pythondeps.toml file. 861 :param online: If True, fall back to PyPI. 862 :param wheels_dir: If specified, search this path for packages. 863 """ 864 absent = [] 865 present = [] 866 canary = None 867 for name, info in group.items(): 868 constraint = _make_version_constraint(info, False) 869 matcher = distlib.version.LegacyMatcher(name + constraint) 870 print(f"mkvenv: checking for {matcher}", file=sys.stderr) 871 ver = _get_version(name) 872 if ( 873 ver is None 874 # Always pass installed package to pip, so that they can be 875 # updated if the requested version changes 876 or not _is_system_package(name) 877 or not matcher.match(distlib.version.LegacyVersion(ver)) 878 ): 879 absent.append(name + _make_version_constraint(info, True)) 880 if len(absent) == 1: 881 canary = info.get("canary", None) 882 else: 883 logger.info("found %s %s", name, ver) 884 present.append(name) 885 886 if present: 887 generate_console_scripts(present) 888 889 if absent: 890 if online or wheels_dir: 891 # Some packages are missing or aren't a suitable version, 892 # install a suitable (possibly vendored) package. 893 print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr) 894 try: 895 pip_install(args=absent, online=online, wheels_dir=wheels_dir) 896 return None 897 except subprocess.CalledProcessError: 898 pass 899 900 return diagnose( 901 absent[0], 902 online, 903 wheels_dir, 904 canary, 905 ) 906 907 return None 908 909 910def ensure( 911 dep_specs: Sequence[str], 912 online: bool = False, 913 wheels_dir: Optional[Union[str, Path]] = None, 914 prog: Optional[str] = None, 915) -> None: 916 """ 917 Use pip to ensure we have the package specified by @dep_specs. 918 919 If the package is already installed, do nothing. If online and 920 wheels_dir are both provided, prefer packages found in wheels_dir 921 first before connecting to PyPI. 922 923 :param dep_specs: 924 PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. 925 :param online: If True, fall back to PyPI. 926 :param wheels_dir: If specified, search this path for packages. 927 :param prog: 928 If specified, use this program name for error diagnostics that will 929 be presented to the user. e.g., 'sphinx-build' can be used as a 930 bellwether for the presence of 'sphinx'. 931 """ 932 933 if not HAVE_DISTLIB: 934 raise Ouch("a usable distlib could not be found, please install it") 935 936 # Convert the depspecs to a dictionary, as if they came 937 # from a section in a pythondeps.toml file 938 group: Dict[str, Dict[str, str]] = {} 939 for spec in dep_specs: 940 name = distlib.version.LegacyMatcher(spec).name 941 group[name] = {} 942 943 spec = spec.strip() 944 pos = len(name) 945 ver = spec[pos:].strip() 946 if ver: 947 group[name]["accepted"] = ver 948 949 if prog: 950 group[name]["canary"] = prog 951 prog = None 952 953 result = _do_ensure(group, online, wheels_dir) 954 if result: 955 # Well, that's not good. 956 if result[1]: 957 raise Ouch(result[0]) 958 raise SystemExit(f"\n{result[0]}\n\n") 959 960 961def _parse_groups(file: str) -> Dict[str, Dict[str, Any]]: 962 if not HAVE_TOMLLIB: 963 if sys.version_info < (3, 11): 964 raise Ouch("found no usable tomli, please install it") 965 966 raise Ouch( 967 "Python >=3.11 does not have tomllib... what have you done!?" 968 ) 969 970 # Use loads() to support both tomli v1.2.x (Ubuntu 22.04, 971 # Debian bullseye-backports) and v2.0.x 972 with open(file, "r", encoding="ascii") as depfile: 973 contents = depfile.read() 974 return tomllib.loads(contents) # type: ignore 975 976 977def ensure_group( 978 file: str, 979 groups: Sequence[str], 980 online: bool = False, 981 wheels_dir: Optional[Union[str, Path]] = None, 982) -> None: 983 """ 984 Use pip to ensure we have the package specified by @dep_specs. 985 986 If the package is already installed, do nothing. If online and 987 wheels_dir are both provided, prefer packages found in wheels_dir 988 first before connecting to PyPI. 989 990 :param dep_specs: 991 PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. 992 :param online: If True, fall back to PyPI. 993 :param wheels_dir: If specified, search this path for packages. 994 """ 995 996 if not HAVE_DISTLIB: 997 raise Ouch("found no usable distlib, please install it") 998 999 parsed_deps = _parse_groups(file) 1000 1001 to_install: Dict[str, Dict[str, str]] = {} 1002 for group in groups: 1003 try: 1004 to_install.update(parsed_deps[group]) 1005 except KeyError as exc: 1006 raise Ouch(f"group {group} not defined") from exc 1007 1008 result = _do_ensure(to_install, online, wheels_dir) 1009 if result: 1010 # Well, that's not good. 1011 if result[1]: 1012 raise Ouch(result[0]) 1013 raise SystemExit(f"\n{result[0]}\n\n") 1014 1015 1016def post_venv_setup() -> None: 1017 """ 1018 This is intended to be run *inside the venv* after it is created. 1019 """ 1020 logger.debug("post_venv_setup()") 1021 # Test for a broken pip (Debian 10 or derivative?) and fix it if needed 1022 if not checkpip(): 1023 # Finally, generate a 'pip' script so the venv is usable in a normal 1024 # way from the CLI. This only happens when we inherited pip from a 1025 # parent/system-site and haven't run ensurepip in some way. 1026 generate_console_scripts(["pip"]) 1027 1028 1029def _add_create_subcommand(subparsers: Any) -> None: 1030 subparser = subparsers.add_parser("create", help="create a venv") 1031 subparser.add_argument( 1032 "target", 1033 type=str, 1034 action="store", 1035 help="Target directory to install virtual environment into.", 1036 ) 1037 1038 1039def _add_post_init_subcommand(subparsers: Any) -> None: 1040 subparsers.add_parser("post_init", help="post-venv initialization") 1041 1042 1043def _add_ensuregroup_subcommand(subparsers: Any) -> None: 1044 subparser = subparsers.add_parser( 1045 "ensuregroup", 1046 help="Ensure that the specified package group is installed.", 1047 ) 1048 subparser.add_argument( 1049 "--online", 1050 action="store_true", 1051 help="Install packages from PyPI, if necessary.", 1052 ) 1053 subparser.add_argument( 1054 "--dir", 1055 type=str, 1056 action="store", 1057 help="Path to vendored packages where we may install from.", 1058 ) 1059 subparser.add_argument( 1060 "file", 1061 type=str, 1062 action="store", 1063 help=("Path to a TOML file describing package groups"), 1064 ) 1065 subparser.add_argument( 1066 "group", 1067 type=str, 1068 action="store", 1069 help="One or more package group names", 1070 nargs="+", 1071 ) 1072 1073 1074def _add_ensure_subcommand(subparsers: Any) -> None: 1075 subparser = subparsers.add_parser( 1076 "ensure", help="Ensure that the specified package is installed." 1077 ) 1078 subparser.add_argument( 1079 "--online", 1080 action="store_true", 1081 help="Install packages from PyPI, if necessary.", 1082 ) 1083 subparser.add_argument( 1084 "--dir", 1085 type=str, 1086 action="store", 1087 help="Path to vendored packages where we may install from.", 1088 ) 1089 subparser.add_argument( 1090 "--diagnose", 1091 type=str, 1092 action="store", 1093 help=( 1094 "Name of a shell utility to use for " 1095 "diagnostics if this command fails." 1096 ), 1097 ) 1098 subparser.add_argument( 1099 "dep_specs", 1100 type=str, 1101 action="store", 1102 help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'", 1103 nargs="+", 1104 ) 1105 1106 1107def main() -> int: 1108 """CLI interface to make_qemu_venv. See module docstring.""" 1109 if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"): 1110 # You're welcome. 1111 logging.basicConfig(level=logging.DEBUG) 1112 else: 1113 if os.environ.get("V"): 1114 logging.basicConfig(level=logging.INFO) 1115 1116 parser = argparse.ArgumentParser( 1117 prog="mkvenv", 1118 description="QEMU pyvenv bootstrapping utility", 1119 ) 1120 subparsers = parser.add_subparsers( 1121 title="Commands", 1122 dest="command", 1123 required=True, 1124 metavar="command", 1125 help="Description", 1126 ) 1127 1128 _add_create_subcommand(subparsers) 1129 _add_post_init_subcommand(subparsers) 1130 _add_ensure_subcommand(subparsers) 1131 _add_ensuregroup_subcommand(subparsers) 1132 1133 args = parser.parse_args() 1134 try: 1135 if args.command == "create": 1136 make_venv( 1137 args.target, 1138 system_site_packages=True, 1139 clear=True, 1140 ) 1141 if args.command == "post_init": 1142 post_venv_setup() 1143 if args.command == "ensure": 1144 ensure( 1145 dep_specs=args.dep_specs, 1146 online=args.online, 1147 wheels_dir=args.dir, 1148 prog=args.diagnose, 1149 ) 1150 if args.command == "ensuregroup": 1151 ensure_group( 1152 file=args.file, 1153 groups=args.group, 1154 online=args.online, 1155 wheels_dir=args.dir, 1156 ) 1157 logger.debug("mkvenv.py %s: exiting", args.command) 1158 except Ouch as exc: 1159 print("\n*** Ouch! ***\n", file=sys.stderr) 1160 print(str(exc), "\n\n", file=sys.stderr) 1161 return 1 1162 except SystemExit: 1163 raise 1164 except: # pylint: disable=bare-except 1165 logger.exception("mkvenv did not complete successfully:") 1166 return 2 1167 return 0 1168 1169 1170if __name__ == "__main__": 1171 sys.exit(main()) 1172