xref: /openbmc/qemu/python/scripts/mkvenv.py (revision 2f95279a)
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