xref: /qemu/python/scripts/mkvenv.py (revision a675ca4c)
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            PackageNotFoundError,
439            distribution,
440        )
441    except ImportError as exc:
442        logger.debug("%s", str(exc))
443        # Second preference: Commonly available PyPI backport
444        from importlib_metadata import (  # type: ignore
445            PackageNotFoundError,
446            distribution,
447        )
448
449    def _generator() -> Iterator[str]:
450        for package in packages:
451            try:
452                entry_points = distribution(package).entry_points
453            except PackageNotFoundError:
454                continue
455
456            # The EntryPoints type is only available in 3.10+,
457            # treat this as a vanilla list and filter it ourselves.
458            entry_points = filter(
459                lambda ep: ep.group == "console_scripts", entry_points
460            )
461
462            for entry_point in entry_points:
463                yield f"{entry_point.name} = {entry_point.value}"
464
465    return _generator()
466
467
468def _gen_pkg_resources(packages: Sequence[str]) -> Iterator[str]:
469    # pylint: disable=import-outside-toplevel
470    # Bundled with setuptools; has a good chance of being available.
471    import pkg_resources
472
473    def _generator() -> Iterator[str]:
474        for package in packages:
475            try:
476                eps = pkg_resources.get_entry_map(package, "console_scripts")
477            except pkg_resources.DistributionNotFound:
478                continue
479
480            for entry_point in eps.values():
481                yield str(entry_point)
482
483    return _generator()
484
485
486def generate_console_scripts(
487    packages: Sequence[str],
488    python_path: Optional[str] = None,
489    bin_path: Optional[str] = None,
490) -> None:
491    """
492    Generate script shims for console_script entry points in @packages.
493    """
494    if python_path is None:
495        python_path = sys.executable
496    if bin_path is None:
497        bin_path = sysconfig.get_path("scripts")
498        assert bin_path is not None
499
500    logger.debug(
501        "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)",
502        packages,
503        python_path,
504        bin_path,
505    )
506
507    if not packages:
508        return
509
510    def _get_entry_points() -> Iterator[str]:
511        """Python 3.7 compatibility shim for iterating entry points."""
512        # Python 3.8+, or Python 3.7 with importlib_metadata installed.
513        try:
514            return _gen_importlib(packages)
515        except ImportError as exc:
516            logger.debug("%s", str(exc))
517
518        # Python 3.7 with setuptools installed.
519        try:
520            return _gen_pkg_resources(packages)
521        except ImportError as exc:
522            logger.debug("%s", str(exc))
523            raise Ouch(
524                "Neither importlib.metadata nor pkg_resources found, "
525                "can't generate console script shims.\n"
526                "Use Python 3.8+, or install importlib-metadata or setuptools."
527            ) from exc
528
529    maker = distlib.scripts.ScriptMaker(None, bin_path)
530    maker.variants = {""}
531    maker.clobber = False
532
533    for entry_point in _get_entry_points():
534        for filename in maker.make(entry_point):
535            logger.debug("wrote console_script '%s'", filename)
536
537
538def checkpip() -> bool:
539    """
540    Debian10 has a pip that's broken when used inside of a virtual environment.
541
542    We try to detect and correct that case here.
543    """
544    try:
545        # pylint: disable=import-outside-toplevel,unused-import,import-error
546        # pylint: disable=redefined-outer-name
547        import pip._internal  # type: ignore  # noqa: F401
548
549        logger.debug("pip appears to be working correctly.")
550        return False
551    except ModuleNotFoundError as exc:
552        if exc.name == "pip._internal":
553            # Uh, fair enough. They did say "internal".
554            # Let's just assume it's fine.
555            return False
556        logger.warning("pip appears to be malfunctioning: %s", str(exc))
557
558    check_ensurepip("pip appears to be non-functional, and ")
559
560    logger.debug("Attempting to repair pip ...")
561    subprocess.run(
562        (sys.executable, "-m", "ensurepip"),
563        stdout=subprocess.DEVNULL,
564        check=True,
565    )
566    logger.debug("Pip is now (hopefully) repaired!")
567    return True
568
569
570def pkgname_from_depspec(dep_spec: str) -> str:
571    """
572    Parse package name out of a PEP-508 depspec.
573
574    See https://peps.python.org/pep-0508/#names
575    """
576    match = re.match(
577        r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE
578    )
579    if not match:
580        raise ValueError(
581            f"dep_spec '{dep_spec}'"
582            " does not appear to contain a valid package name"
583        )
584    return match.group(0)
585
586
587def _get_path_importlib(package: str) -> Optional[str]:
588    # pylint: disable=import-outside-toplevel
589    # pylint: disable=no-name-in-module
590    # pylint: disable=import-error
591    try:
592        # First preference: Python 3.8+ stdlib
593        from importlib.metadata import (  # type: ignore
594            PackageNotFoundError,
595            distribution,
596        )
597    except ImportError as exc:
598        logger.debug("%s", str(exc))
599        # Second preference: Commonly available PyPI backport
600        from importlib_metadata import (  # type: ignore
601            PackageNotFoundError,
602            distribution,
603        )
604
605    try:
606        return str(distribution(package).locate_file("."))
607    except PackageNotFoundError:
608        return None
609
610
611def _get_path_pkg_resources(package: str) -> Optional[str]:
612    # pylint: disable=import-outside-toplevel
613    # Bundled with setuptools; has a good chance of being available.
614    import pkg_resources
615
616    try:
617        return str(pkg_resources.get_distribution(package).location)
618    except pkg_resources.DistributionNotFound:
619        return None
620
621
622def _get_path(package: str) -> Optional[str]:
623    try:
624        return _get_path_importlib(package)
625    except ImportError as exc:
626        logger.debug("%s", str(exc))
627
628    try:
629        return _get_path_pkg_resources(package)
630    except ImportError as exc:
631        logger.debug("%s", str(exc))
632        raise Ouch(
633            "Neither importlib.metadata nor pkg_resources found. "
634            "Use Python 3.8+, or install importlib-metadata or setuptools."
635        ) from exc
636
637
638def _path_is_prefix(prefix: Optional[str], path: str) -> bool:
639    try:
640        return (
641            prefix is not None and os.path.commonpath([prefix, path]) == prefix
642        )
643    except ValueError:
644        return False
645
646
647def _is_system_package(package: str) -> bool:
648    path = _get_path(package)
649    return path is not None and not (
650        _path_is_prefix(sysconfig.get_path("purelib"), path)
651        or _path_is_prefix(sysconfig.get_path("platlib"), path)
652    )
653
654
655def _get_version_importlib(package: str) -> Optional[str]:
656    # pylint: disable=import-outside-toplevel
657    # pylint: disable=no-name-in-module
658    # pylint: disable=import-error
659    try:
660        # First preference: Python 3.8+ stdlib
661        from importlib.metadata import (  # type: ignore
662            PackageNotFoundError,
663            distribution,
664        )
665    except ImportError as exc:
666        logger.debug("%s", str(exc))
667        # Second preference: Commonly available PyPI backport
668        from importlib_metadata import (  # type: ignore
669            PackageNotFoundError,
670            distribution,
671        )
672
673    try:
674        return str(distribution(package).version)
675    except PackageNotFoundError:
676        return None
677
678
679def _get_version_pkg_resources(package: str) -> Optional[str]:
680    # pylint: disable=import-outside-toplevel
681    # Bundled with setuptools; has a good chance of being available.
682    import pkg_resources
683
684    try:
685        return str(pkg_resources.get_distribution(package).version)
686    except pkg_resources.DistributionNotFound:
687        return None
688
689
690def _get_version(package: str) -> Optional[str]:
691    try:
692        return _get_version_importlib(package)
693    except ImportError as exc:
694        logger.debug("%s", str(exc))
695
696    try:
697        return _get_version_pkg_resources(package)
698    except ImportError as exc:
699        logger.debug("%s", str(exc))
700        raise Ouch(
701            "Neither importlib.metadata nor pkg_resources found. "
702            "Use Python 3.8+, or install importlib-metadata or setuptools."
703        ) from exc
704
705
706def diagnose(
707    dep_spec: str,
708    online: bool,
709    wheels_dir: Optional[Union[str, Path]],
710    prog: Optional[str],
711) -> Tuple[str, bool]:
712    """
713    Offer a summary to the user as to why a package failed to be installed.
714
715    :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5'
716    :param online: Did we allow PyPI access?
717    :param prog:
718        Optionally, a shell program name that can be used as a
719        bellwether to detect if this program is installed elsewhere on
720        the system. This is used to offer advice when a program is
721        detected for a different python version.
722    :param wheels_dir:
723        Optionally, a directory that was searched for vendored packages.
724    """
725    # pylint: disable=too-many-branches
726
727    # Some errors are not particularly serious
728    bad = False
729
730    pkg_name = pkgname_from_depspec(dep_spec)
731    pkg_version = _get_version(pkg_name)
732
733    lines = []
734
735    if pkg_version:
736        lines.append(
737            f"Python package '{pkg_name}' version '{pkg_version}' was found,"
738            " but isn't suitable."
739        )
740    else:
741        lines.append(
742            f"Python package '{pkg_name}' was not found nor installed."
743        )
744
745    if wheels_dir:
746        lines.append(
747            "No suitable version found in, or failed to install from"
748            f" '{wheels_dir}'."
749        )
750        bad = True
751
752    if online:
753        lines.append("A suitable version could not be obtained from PyPI.")
754        bad = True
755    else:
756        lines.append(
757            "mkvenv was configured to operate offline and did not check PyPI."
758        )
759
760    if prog and not pkg_version:
761        which = shutil.which(prog)
762        if which:
763            if sys.base_prefix in site.PREFIXES:
764                pypath = Path(sys.executable).resolve()
765                lines.append(
766                    f"'{prog}' was detected on your system at '{which}', "
767                    f"but the Python package '{pkg_name}' was not found by "
768                    f"this Python interpreter ('{pypath}'). "
769                    f"Typically this means that '{prog}' has been installed "
770                    "against a different Python interpreter on your system."
771                )
772            else:
773                lines.append(
774                    f"'{prog}' was detected on your system at '{which}', "
775                    "but the build is using an isolated virtual environment."
776                )
777            bad = True
778
779    lines = [f" • {line}" for line in lines]
780    if bad:
781        lines.insert(0, f"Could not provide build dependency '{dep_spec}':")
782    else:
783        lines.insert(0, f"'{dep_spec}' not found:")
784    return os.linesep.join(lines), bad
785
786
787def pip_install(
788    args: Sequence[str],
789    online: bool = False,
790    wheels_dir: Optional[Union[str, Path]] = None,
791) -> None:
792    """
793    Use pip to install a package or package(s) as specified in @args.
794    """
795    loud = bool(
796        os.environ.get("DEBUG")
797        or os.environ.get("GITLAB_CI")
798        or os.environ.get("V")
799    )
800
801    full_args = [
802        sys.executable,
803        "-m",
804        "pip",
805        "install",
806        "--disable-pip-version-check",
807        "-v" if loud else "-q",
808    ]
809    if not online:
810        full_args += ["--no-index"]
811    if wheels_dir:
812        full_args += ["--find-links", f"file://{str(wheels_dir)}"]
813    full_args += list(args)
814    subprocess.run(
815        full_args,
816        check=True,
817    )
818
819
820def _make_version_constraint(info: Dict[str, str], install: bool) -> str:
821    """
822    Construct the version constraint part of a PEP 508 dependency
823    specification (for example '>=0.61.5') from the accepted and
824    installed keys of the provided dictionary.
825
826    :param info: A dictionary corresponding to a TOML key-value list.
827    :param install: True generates install constraints, False generates
828        presence constraints
829    """
830    if install and "installed" in info:
831        return "==" + info["installed"]
832
833    dep_spec = info.get("accepted", "")
834    dep_spec = dep_spec.strip()
835    # Double check that they didn't just use a version number
836    if dep_spec and dep_spec[0] not in "!~><=(":
837        raise Ouch(
838            "invalid dependency specifier " + dep_spec + " in dependency file"
839        )
840
841    return dep_spec
842
843
844def _do_ensure(
845    group: Dict[str, Dict[str, str]],
846    online: bool = False,
847    wheels_dir: Optional[Union[str, Path]] = None,
848) -> Optional[Tuple[str, bool]]:
849    """
850    Use pip to ensure we have the packages specified in @group.
851
852    If the packages are already installed, do nothing. If online and
853    wheels_dir are both provided, prefer packages found in wheels_dir
854    first before connecting to PyPI.
855
856    :param group: A dictionary of dictionaries, corresponding to a
857        section in a pythondeps.toml file.
858    :param online: If True, fall back to PyPI.
859    :param wheels_dir: If specified, search this path for packages.
860    """
861    absent = []
862    present = []
863    canary = None
864    for name, info in group.items():
865        constraint = _make_version_constraint(info, False)
866        matcher = distlib.version.LegacyMatcher(name + constraint)
867        print(f"mkvenv: checking for {matcher}", file=sys.stderr)
868        ver = _get_version(name)
869        if (
870            ver is None
871            # Always pass installed package to pip, so that they can be
872            # updated if the requested version changes
873            or not _is_system_package(name)
874            or not matcher.match(distlib.version.LegacyVersion(ver))
875        ):
876            absent.append(name + _make_version_constraint(info, True))
877            if len(absent) == 1:
878                canary = info.get("canary", None)
879        else:
880            logger.info("found %s %s", name, ver)
881            present.append(name)
882
883    if present:
884        generate_console_scripts(present)
885
886    if absent:
887        if online or wheels_dir:
888            # Some packages are missing or aren't a suitable version,
889            # install a suitable (possibly vendored) package.
890            print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr)
891            try:
892                pip_install(args=absent, online=online, wheels_dir=wheels_dir)
893                return None
894            except subprocess.CalledProcessError:
895                pass
896
897        return diagnose(
898            absent[0],
899            online,
900            wheels_dir,
901            canary,
902        )
903
904    return None
905
906
907def ensure(
908    dep_specs: Sequence[str],
909    online: bool = False,
910    wheels_dir: Optional[Union[str, Path]] = None,
911    prog: Optional[str] = None,
912) -> None:
913    """
914    Use pip to ensure we have the package specified by @dep_specs.
915
916    If the package is already installed, do nothing. If online and
917    wheels_dir are both provided, prefer packages found in wheels_dir
918    first before connecting to PyPI.
919
920    :param dep_specs:
921        PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
922    :param online: If True, fall back to PyPI.
923    :param wheels_dir: If specified, search this path for packages.
924    :param prog:
925        If specified, use this program name for error diagnostics that will
926        be presented to the user. e.g., 'sphinx-build' can be used as a
927        bellwether for the presence of 'sphinx'.
928    """
929
930    if not HAVE_DISTLIB:
931        raise Ouch("a usable distlib could not be found, please install it")
932
933    # Convert the depspecs to a dictionary, as if they came
934    # from a section in a pythondeps.toml file
935    group: Dict[str, Dict[str, str]] = {}
936    for spec in dep_specs:
937        name = distlib.version.LegacyMatcher(spec).name
938        group[name] = {}
939
940        spec = spec.strip()
941        pos = len(name)
942        ver = spec[pos:].strip()
943        if ver:
944            group[name]["accepted"] = ver
945
946        if prog:
947            group[name]["canary"] = prog
948            prog = None
949
950    result = _do_ensure(group, online, wheels_dir)
951    if result:
952        # Well, that's not good.
953        if result[1]:
954            raise Ouch(result[0])
955        raise SystemExit(f"\n{result[0]}\n\n")
956
957
958def _parse_groups(file: str) -> Dict[str, Dict[str, Any]]:
959    if not HAVE_TOMLLIB:
960        if sys.version_info < (3, 11):
961            raise Ouch("found no usable tomli, please install it")
962
963        raise Ouch(
964            "Python >=3.11 does not have tomllib... what have you done!?"
965        )
966
967    # Use loads() to support both tomli v1.2.x (Ubuntu 22.04,
968    # Debian bullseye-backports) and v2.0.x
969    with open(file, "r", encoding="ascii") as depfile:
970        contents = depfile.read()
971        return tomllib.loads(contents)  # type: ignore
972
973
974def ensure_group(
975    file: str,
976    groups: Sequence[str],
977    online: bool = False,
978    wheels_dir: Optional[Union[str, Path]] = None,
979) -> None:
980    """
981    Use pip to ensure we have the package specified by @dep_specs.
982
983    If the package is already installed, do nothing. If online and
984    wheels_dir are both provided, prefer packages found in wheels_dir
985    first before connecting to PyPI.
986
987    :param dep_specs:
988        PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
989    :param online: If True, fall back to PyPI.
990    :param wheels_dir: If specified, search this path for packages.
991    """
992
993    if not HAVE_DISTLIB:
994        raise Ouch("found no usable distlib, please install it")
995
996    parsed_deps = _parse_groups(file)
997
998    to_install: Dict[str, Dict[str, str]] = {}
999    for group in groups:
1000        try:
1001            to_install.update(parsed_deps[group])
1002        except KeyError as exc:
1003            raise Ouch(f"group {group} not defined") from exc
1004
1005    result = _do_ensure(to_install, online, wheels_dir)
1006    if result:
1007        # Well, that's not good.
1008        if result[1]:
1009            raise Ouch(result[0])
1010        raise SystemExit(f"\n{result[0]}\n\n")
1011
1012
1013def post_venv_setup() -> None:
1014    """
1015    This is intended to be run *inside the venv* after it is created.
1016    """
1017    logger.debug("post_venv_setup()")
1018    # Test for a broken pip (Debian 10 or derivative?) and fix it if needed
1019    if not checkpip():
1020        # Finally, generate a 'pip' script so the venv is usable in a normal
1021        # way from the CLI. This only happens when we inherited pip from a
1022        # parent/system-site and haven't run ensurepip in some way.
1023        generate_console_scripts(["pip"])
1024
1025
1026def _add_create_subcommand(subparsers: Any) -> None:
1027    subparser = subparsers.add_parser("create", help="create a venv")
1028    subparser.add_argument(
1029        "target",
1030        type=str,
1031        action="store",
1032        help="Target directory to install virtual environment into.",
1033    )
1034
1035
1036def _add_post_init_subcommand(subparsers: Any) -> None:
1037    subparsers.add_parser("post_init", help="post-venv initialization")
1038
1039
1040def _add_ensuregroup_subcommand(subparsers: Any) -> None:
1041    subparser = subparsers.add_parser(
1042        "ensuregroup",
1043        help="Ensure that the specified package group is installed.",
1044    )
1045    subparser.add_argument(
1046        "--online",
1047        action="store_true",
1048        help="Install packages from PyPI, if necessary.",
1049    )
1050    subparser.add_argument(
1051        "--dir",
1052        type=str,
1053        action="store",
1054        help="Path to vendored packages where we may install from.",
1055    )
1056    subparser.add_argument(
1057        "file",
1058        type=str,
1059        action="store",
1060        help=("Path to a TOML file describing package groups"),
1061    )
1062    subparser.add_argument(
1063        "group",
1064        type=str,
1065        action="store",
1066        help="One or more package group names",
1067        nargs="+",
1068    )
1069
1070
1071def _add_ensure_subcommand(subparsers: Any) -> None:
1072    subparser = subparsers.add_parser(
1073        "ensure", help="Ensure that the specified package is installed."
1074    )
1075    subparser.add_argument(
1076        "--online",
1077        action="store_true",
1078        help="Install packages from PyPI, if necessary.",
1079    )
1080    subparser.add_argument(
1081        "--dir",
1082        type=str,
1083        action="store",
1084        help="Path to vendored packages where we may install from.",
1085    )
1086    subparser.add_argument(
1087        "--diagnose",
1088        type=str,
1089        action="store",
1090        help=(
1091            "Name of a shell utility to use for "
1092            "diagnostics if this command fails."
1093        ),
1094    )
1095    subparser.add_argument(
1096        "dep_specs",
1097        type=str,
1098        action="store",
1099        help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'",
1100        nargs="+",
1101    )
1102
1103
1104def main() -> int:
1105    """CLI interface to make_qemu_venv. See module docstring."""
1106    if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"):
1107        # You're welcome.
1108        logging.basicConfig(level=logging.DEBUG)
1109    else:
1110        if os.environ.get("V"):
1111            logging.basicConfig(level=logging.INFO)
1112
1113    parser = argparse.ArgumentParser(
1114        prog="mkvenv",
1115        description="QEMU pyvenv bootstrapping utility",
1116    )
1117    subparsers = parser.add_subparsers(
1118        title="Commands",
1119        dest="command",
1120        required=True,
1121        metavar="command",
1122        help="Description",
1123    )
1124
1125    _add_create_subcommand(subparsers)
1126    _add_post_init_subcommand(subparsers)
1127    _add_ensure_subcommand(subparsers)
1128    _add_ensuregroup_subcommand(subparsers)
1129
1130    args = parser.parse_args()
1131    try:
1132        if args.command == "create":
1133            make_venv(
1134                args.target,
1135                system_site_packages=True,
1136                clear=True,
1137            )
1138        if args.command == "post_init":
1139            post_venv_setup()
1140        if args.command == "ensure":
1141            ensure(
1142                dep_specs=args.dep_specs,
1143                online=args.online,
1144                wheels_dir=args.dir,
1145                prog=args.diagnose,
1146            )
1147        if args.command == "ensuregroup":
1148            ensure_group(
1149                file=args.file,
1150                groups=args.group,
1151                online=args.online,
1152                wheels_dir=args.dir,
1153            )
1154        logger.debug("mkvenv.py %s: exiting", args.command)
1155    except Ouch as exc:
1156        print("\n*** Ouch! ***\n", file=sys.stderr)
1157        print(str(exc), "\n\n", file=sys.stderr)
1158        return 1
1159    except SystemExit:
1160        raise
1161    except:  # pylint: disable=bare-except
1162        logger.exception("mkvenv did not complete successfully:")
1163        return 2
1164    return 0
1165
1166
1167if __name__ == "__main__":
1168    sys.exit(main())
1169