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