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