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