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