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