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 # pylint 3.3 bug: 383 # pylint: disable=raising-non-exception, raise-missing-from 384 385 # Some versions of the venv module raise SystemExit; *nasty*! 386 # We want the exception that prompted it. It might be a subprocess 387 # error that has output we *really* want to see. 388 logger.debug("Intercepted SystemExit from EnvBuilder.create()") 389 raise exc.__cause__ or exc.__context__ or exc 390 logger.debug("builder.create() finished") 391 except subprocess.CalledProcessError as exc: 392 logger.error("mkvenv subprocess failed:") 393 logger.error("cmd: %s", exc.cmd) 394 logger.error("returncode: %d", exc.returncode) 395 396 def _stringify(data: Union[str, bytes]) -> str: 397 if isinstance(data, bytes): 398 return data.decode() 399 return data 400 401 lines = [] 402 if exc.stdout: 403 lines.append("========== stdout ==========") 404 lines.append(_stringify(exc.stdout)) 405 lines.append("============================") 406 if exc.stderr: 407 lines.append("========== stderr ==========") 408 lines.append(_stringify(exc.stderr)) 409 lines.append("============================") 410 if lines: 411 logger.error(os.linesep.join(lines)) 412 413 raise Ouch("VENV creation subprocess failed.") from exc 414 415 # print the python executable to stdout for configure. 416 print(builder.get_value("env_exe")) 417 418 419def _get_entry_points(packages: Sequence[str]) -> Iterator[str]: 420 421 def _generator() -> Iterator[str]: 422 for package in packages: 423 try: 424 entry_points: Iterator[EntryPoint] = \ 425 iter(distribution(package).entry_points) 426 except PackageNotFoundError: 427 continue 428 429 # The EntryPoints type is only available in 3.10+, 430 # treat this as a vanilla list and filter it ourselves. 431 entry_points = filter( 432 lambda ep: ep.group == "console_scripts", entry_points 433 ) 434 435 for entry_point in entry_points: 436 yield f"{entry_point.name} = {entry_point.value}" 437 438 return _generator() 439 440 441def generate_console_scripts( 442 packages: Sequence[str], 443 python_path: Optional[str] = None, 444 bin_path: Optional[str] = None, 445) -> None: 446 """ 447 Generate script shims for console_script entry points in @packages. 448 """ 449 if python_path is None: 450 python_path = sys.executable 451 if bin_path is None: 452 bin_path = sysconfig.get_path("scripts") 453 assert bin_path is not None 454 455 logger.debug( 456 "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)", 457 packages, 458 python_path, 459 bin_path, 460 ) 461 462 if not packages: 463 return 464 465 maker = distlib.scripts.ScriptMaker(None, bin_path) 466 maker.variants = {""} 467 maker.clobber = False 468 469 for entry_point in _get_entry_points(packages): 470 for filename in maker.make(entry_point): 471 logger.debug("wrote console_script '%s'", filename) 472 473 474def pkgname_from_depspec(dep_spec: str) -> str: 475 """ 476 Parse package name out of a PEP-508 depspec. 477 478 See https://peps.python.org/pep-0508/#names 479 """ 480 match = re.match( 481 r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE 482 ) 483 if not match: 484 raise ValueError( 485 f"dep_spec '{dep_spec}'" 486 " does not appear to contain a valid package name" 487 ) 488 return match.group(0) 489 490 491def _path_is_prefix(prefix: Optional[str], path: str) -> bool: 492 try: 493 return ( 494 prefix is not None and os.path.commonpath([prefix, path]) == prefix 495 ) 496 except ValueError: 497 return False 498 499 500def _is_system_package(dist: Distribution) -> bool: 501 path = str(dist.locate_file(".")) 502 return not ( 503 _path_is_prefix(sysconfig.get_path("purelib"), path) 504 or _path_is_prefix(sysconfig.get_path("platlib"), path) 505 ) 506 507 508def diagnose( 509 dep_spec: str, 510 online: bool, 511 wheels_dir: Optional[Union[str, Path]], 512 prog: Optional[str], 513) -> Tuple[str, bool]: 514 """ 515 Offer a summary to the user as to why a package failed to be installed. 516 517 :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5' 518 :param online: Did we allow PyPI access? 519 :param prog: 520 Optionally, a shell program name that can be used as a 521 bellwether to detect if this program is installed elsewhere on 522 the system. This is used to offer advice when a program is 523 detected for a different python version. 524 :param wheels_dir: 525 Optionally, a directory that was searched for vendored packages. 526 """ 527 # pylint: disable=too-many-branches 528 529 # Some errors are not particularly serious 530 bad = False 531 532 pkg_name = pkgname_from_depspec(dep_spec) 533 pkg_version: Optional[str] = None 534 try: 535 pkg_version = version(pkg_name) 536 except PackageNotFoundError: 537 pass 538 539 lines = [] 540 541 if pkg_version: 542 lines.append( 543 f"Python package '{pkg_name}' version '{pkg_version}' was found," 544 " but isn't suitable." 545 ) 546 else: 547 lines.append( 548 f"Python package '{pkg_name}' was not found nor installed." 549 ) 550 551 if wheels_dir: 552 lines.append( 553 "No suitable version found in, or failed to install from" 554 f" '{wheels_dir}'." 555 ) 556 bad = True 557 558 if online: 559 lines.append("A suitable version could not be obtained from PyPI.") 560 bad = True 561 else: 562 lines.append( 563 "mkvenv was configured to operate offline and did not check PyPI." 564 ) 565 566 if prog and not pkg_version: 567 which = shutil.which(prog) 568 if which: 569 if sys.base_prefix in site.PREFIXES: 570 pypath = Path(sys.executable).resolve() 571 lines.append( 572 f"'{prog}' was detected on your system at '{which}', " 573 f"but the Python package '{pkg_name}' was not found by " 574 f"this Python interpreter ('{pypath}'). " 575 f"Typically this means that '{prog}' has been installed " 576 "against a different Python interpreter on your system." 577 ) 578 else: 579 lines.append( 580 f"'{prog}' was detected on your system at '{which}', " 581 "but the build is using an isolated virtual environment." 582 ) 583 bad = True 584 585 lines = [f" • {line}" for line in lines] 586 if bad: 587 lines.insert(0, f"Could not provide build dependency '{dep_spec}':") 588 else: 589 lines.insert(0, f"'{dep_spec}' not found:") 590 return os.linesep.join(lines), bad 591 592 593def pip_install( 594 args: Sequence[str], 595 online: bool = False, 596 wheels_dir: Optional[Union[str, Path]] = None, 597) -> None: 598 """ 599 Use pip to install a package or package(s) as specified in @args. 600 """ 601 loud = bool( 602 os.environ.get("DEBUG") 603 or os.environ.get("GITLAB_CI") 604 or os.environ.get("V") 605 ) 606 607 full_args = [ 608 sys.executable, 609 "-m", 610 "pip", 611 "install", 612 "--disable-pip-version-check", 613 "-v" if loud else "-q", 614 ] 615 if not online: 616 full_args += ["--no-index"] 617 if wheels_dir: 618 full_args += ["--find-links", f"file://{str(wheels_dir)}"] 619 full_args += list(args) 620 subprocess.run( 621 full_args, 622 check=True, 623 ) 624 625 626def _make_version_constraint(info: Dict[str, str], install: bool) -> str: 627 """ 628 Construct the version constraint part of a PEP 508 dependency 629 specification (for example '>=0.61.5') from the accepted and 630 installed keys of the provided dictionary. 631 632 :param info: A dictionary corresponding to a TOML key-value list. 633 :param install: True generates install constraints, False generates 634 presence constraints 635 """ 636 if install and "installed" in info: 637 return "==" + info["installed"] 638 639 dep_spec = info.get("accepted", "") 640 dep_spec = dep_spec.strip() 641 # Double check that they didn't just use a version number 642 if dep_spec and dep_spec[0] not in "!~><=(": 643 raise Ouch( 644 "invalid dependency specifier " + dep_spec + " in dependency file" 645 ) 646 647 return dep_spec 648 649 650def _do_ensure( 651 group: Dict[str, Dict[str, str]], 652 online: bool = False, 653 wheels_dir: Optional[Union[str, Path]] = None, 654) -> Optional[Tuple[str, bool]]: 655 """ 656 Use pip to ensure we have the packages specified in @group. 657 658 If the packages are already installed, do nothing. If online and 659 wheels_dir are both provided, prefer packages found in wheels_dir 660 first before connecting to PyPI. 661 662 :param group: A dictionary of dictionaries, corresponding to a 663 section in a pythondeps.toml file. 664 :param online: If True, fall back to PyPI. 665 :param wheels_dir: If specified, search this path for packages. 666 """ 667 absent = [] 668 present = [] 669 canary = None 670 for name, info in group.items(): 671 constraint = _make_version_constraint(info, False) 672 matcher = distlib.version.LegacyMatcher(name + constraint) 673 print(f"mkvenv: checking for {matcher}", file=sys.stderr) 674 675 dist: Optional[Distribution] = None 676 try: 677 dist = distribution(matcher.name) 678 except PackageNotFoundError: 679 pass 680 681 if ( 682 dist is None 683 # Always pass installed package to pip, so that they can be 684 # updated if the requested version changes 685 or not _is_system_package(dist) 686 or not matcher.match(distlib.version.LegacyVersion(dist.version)) 687 ): 688 absent.append(name + _make_version_constraint(info, True)) 689 if len(absent) == 1: 690 canary = info.get("canary", None) 691 else: 692 logger.info("found %s %s", name, dist.version) 693 present.append(name) 694 695 if present: 696 generate_console_scripts(present) 697 698 if absent: 699 if online or wheels_dir: 700 # Some packages are missing or aren't a suitable version, 701 # install a suitable (possibly vendored) package. 702 print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr) 703 try: 704 pip_install(args=absent, online=online, wheels_dir=wheels_dir) 705 return None 706 except subprocess.CalledProcessError: 707 pass 708 709 return diagnose( 710 absent[0], 711 online, 712 wheels_dir, 713 canary, 714 ) 715 716 return None 717 718 719def _parse_groups(file: str) -> Dict[str, Dict[str, Any]]: 720 if not HAVE_TOMLLIB: 721 if sys.version_info < (3, 11): 722 raise Ouch("found no usable tomli, please install it") 723 724 raise Ouch( 725 "Python >=3.11 does not have tomllib... what have you done!?" 726 ) 727 728 # Use loads() to support both tomli v1.2.x (Ubuntu 22.04, 729 # Debian bullseye-backports) and v2.0.x 730 with open(file, "r", encoding="ascii") as depfile: 731 contents = depfile.read() 732 return tomllib.loads(contents) # type: ignore 733 734 735def ensure_group( 736 file: str, 737 groups: Sequence[str], 738 online: bool = False, 739 wheels_dir: Optional[Union[str, Path]] = None, 740) -> None: 741 """ 742 Use pip to ensure we have the package specified by @dep_specs. 743 744 If the package is already installed, do nothing. If online and 745 wheels_dir are both provided, prefer packages found in wheels_dir 746 first before connecting to PyPI. 747 748 :param dep_specs: 749 PEP 508 dependency specifications. e.g. ['meson>=0.61.5']. 750 :param online: If True, fall back to PyPI. 751 :param wheels_dir: If specified, search this path for packages. 752 """ 753 754 if not HAVE_DISTLIB: 755 raise Ouch("found no usable distlib, please install it") 756 757 parsed_deps = _parse_groups(file) 758 759 to_install: Dict[str, Dict[str, str]] = {} 760 for group in groups: 761 try: 762 to_install.update(parsed_deps[group]) 763 except KeyError as exc: 764 raise Ouch(f"group {group} not defined") from exc 765 766 result = _do_ensure(to_install, online, wheels_dir) 767 if result: 768 # Well, that's not good. 769 if result[1]: 770 raise Ouch(result[0]) 771 raise SystemExit(f"\n{result[0]}\n\n") 772 773 774def post_venv_setup() -> None: 775 """ 776 This is intended to be run *inside the venv* after it is created. 777 """ 778 logger.debug("post_venv_setup()") 779 # Generate a 'pip' script so the venv is usable in a normal 780 # way from the CLI. This only happens when we inherited pip from a 781 # parent/system-site and haven't run ensurepip in some way. 782 generate_console_scripts(["pip"]) 783 784 785def _add_create_subcommand(subparsers: Any) -> None: 786 subparser = subparsers.add_parser("create", help="create a venv") 787 subparser.add_argument( 788 "target", 789 type=str, 790 action="store", 791 help="Target directory to install virtual environment into.", 792 ) 793 794 795def _add_post_init_subcommand(subparsers: Any) -> None: 796 subparsers.add_parser("post_init", help="post-venv initialization") 797 798 799def _add_ensuregroup_subcommand(subparsers: Any) -> None: 800 subparser = subparsers.add_parser( 801 "ensuregroup", 802 help="Ensure that the specified package group is installed.", 803 ) 804 subparser.add_argument( 805 "--online", 806 action="store_true", 807 help="Install packages from PyPI, if necessary.", 808 ) 809 subparser.add_argument( 810 "--dir", 811 type=str, 812 action="store", 813 help="Path to vendored packages where we may install from.", 814 ) 815 subparser.add_argument( 816 "file", 817 type=str, 818 action="store", 819 help=("Path to a TOML file describing package groups"), 820 ) 821 subparser.add_argument( 822 "group", 823 type=str, 824 action="store", 825 help="One or more package group names", 826 nargs="+", 827 ) 828 829 830def main() -> int: 831 """CLI interface to make_qemu_venv. See module docstring.""" 832 if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"): 833 # You're welcome. 834 logging.basicConfig(level=logging.DEBUG) 835 else: 836 if os.environ.get("V"): 837 logging.basicConfig(level=logging.INFO) 838 839 parser = argparse.ArgumentParser( 840 prog="mkvenv", 841 description="QEMU pyvenv bootstrapping utility", 842 ) 843 subparsers = parser.add_subparsers( 844 title="Commands", 845 dest="command", 846 required=True, 847 metavar="command", 848 help="Description", 849 ) 850 851 _add_create_subcommand(subparsers) 852 _add_post_init_subcommand(subparsers) 853 _add_ensuregroup_subcommand(subparsers) 854 855 args = parser.parse_args() 856 try: 857 if args.command == "create": 858 make_venv( 859 args.target, 860 system_site_packages=True, 861 clear=True, 862 ) 863 if args.command == "post_init": 864 post_venv_setup() 865 if args.command == "ensuregroup": 866 ensure_group( 867 file=args.file, 868 groups=args.group, 869 online=args.online, 870 wheels_dir=args.dir, 871 ) 872 logger.debug("mkvenv.py %s: exiting", args.command) 873 except Ouch as exc: 874 print("\n*** Ouch! ***\n", file=sys.stderr) 875 print(str(exc), "\n\n", file=sys.stderr) 876 return 1 877 except SystemExit: 878 raise 879 except: # pylint: disable=bare-except 880 logger.exception("mkvenv did not complete successfully:") 881 return 2 882 return 0 883 884 885if __name__ == "__main__": 886 sys.exit(main()) 887