xref: /openbmc/qemu/python/scripts/mkvenv.py (revision 71ed611c)
1dd84028fSJohn Snow"""
2dd84028fSJohn Snowmkvenv - QEMU pyvenv bootstrapping utility
3dd84028fSJohn Snow
4dd84028fSJohn Snowusage: mkvenv [-h] command ...
5dd84028fSJohn Snow
6dd84028fSJohn SnowQEMU pyvenv bootstrapping utility
7dd84028fSJohn Snow
8dd84028fSJohn Snowoptions:
9dd84028fSJohn Snow  -h, --help  show this help message and exit
10dd84028fSJohn Snow
11dd84028fSJohn SnowCommands:
12dd84028fSJohn Snow  command     Description
13dd84028fSJohn Snow    create    create a venv
14f1ad527fSJohn Snow    post_init
15f1ad527fSJohn Snow              post-venv initialization
16c5538eedSJohn Snow    ensure    Ensure that the specified package is installed.
17*71ed611cSPaolo Bonzini    ensuregroup
18*71ed611cSPaolo Bonzini              Ensure that the specified package group is installed.
19dd84028fSJohn Snow
20dd84028fSJohn Snow--------------------------------------------------
21dd84028fSJohn Snow
22dd84028fSJohn Snowusage: mkvenv create [-h] target
23dd84028fSJohn Snow
24dd84028fSJohn Snowpositional arguments:
25dd84028fSJohn Snow  target      Target directory to install virtual environment into.
26dd84028fSJohn Snow
27dd84028fSJohn Snowoptions:
28dd84028fSJohn Snow  -h, --help  show this help message and exit
29dd84028fSJohn Snow
30c5538eedSJohn Snow--------------------------------------------------
31c5538eedSJohn Snow
32f1ad527fSJohn Snowusage: mkvenv post_init [-h]
33f1ad527fSJohn Snow
34f1ad527fSJohn Snowoptions:
35f1ad527fSJohn Snow  -h, --help         show this help message and exit
36f1ad527fSJohn Snow
37f1ad527fSJohn Snow--------------------------------------------------
38f1ad527fSJohn Snow
39c5538eedSJohn Snowusage: mkvenv ensure [-h] [--online] [--dir DIR] dep_spec...
40c5538eedSJohn Snow
41c5538eedSJohn Snowpositional arguments:
42c5538eedSJohn Snow  dep_spec    PEP 508 Dependency specification, e.g. 'meson>=0.61.5'
43c5538eedSJohn Snow
44c5538eedSJohn Snowoptions:
45c5538eedSJohn Snow  -h, --help  show this help message and exit
46c5538eedSJohn Snow  --online    Install packages from PyPI, if necessary.
47c5538eedSJohn Snow  --dir DIR   Path to vendored packages where we may install from.
48c5538eedSJohn Snow
49*71ed611cSPaolo Bonzini--------------------------------------------------
50*71ed611cSPaolo Bonzini
51*71ed611cSPaolo Bonziniusage: mkvenv ensuregroup [-h] [--online] [--dir DIR] file group...
52*71ed611cSPaolo Bonzini
53*71ed611cSPaolo Bonzinipositional arguments:
54*71ed611cSPaolo Bonzini  file        pointer to a TOML file
55*71ed611cSPaolo Bonzini  group       section name in the TOML file
56*71ed611cSPaolo Bonzini
57*71ed611cSPaolo Bonzinioptions:
58*71ed611cSPaolo Bonzini  -h, --help  show this help message and exit
59*71ed611cSPaolo Bonzini  --online    Install packages from PyPI, if necessary.
60*71ed611cSPaolo Bonzini  --dir DIR   Path to vendored packages where we may install from.
61*71ed611cSPaolo Bonzini
62dd84028fSJohn Snow"""
63dd84028fSJohn Snow
640f1ec070SPaolo Bonzini# The duplication between importlib and pkg_resources does not help
650f1ec070SPaolo Bonzini# pylint: disable=too-many-lines
660f1ec070SPaolo Bonzini
67dd84028fSJohn Snow# Copyright (C) 2022-2023 Red Hat, Inc.
68dd84028fSJohn Snow#
69dd84028fSJohn Snow# Authors:
70dd84028fSJohn Snow#  John Snow <jsnow@redhat.com>
71dd84028fSJohn Snow#  Paolo Bonzini <pbonzini@redhat.com>
72dd84028fSJohn Snow#
73dd84028fSJohn Snow# This work is licensed under the terms of the GNU GPL, version 2 or
74dd84028fSJohn Snow# later. See the COPYING file in the top-level directory.
75dd84028fSJohn Snow
76dd84028fSJohn Snowimport argparse
77a9dbde71SJohn Snowfrom importlib.util import find_spec
78dd84028fSJohn Snowimport logging
79dd84028fSJohn Snowimport os
80dd84028fSJohn Snowfrom pathlib import Path
814695a22eSJohn Snowimport re
824695a22eSJohn Snowimport shutil
83dee01b82SJohn Snowimport site
84dd84028fSJohn Snowimport subprocess
85dd84028fSJohn Snowimport sys
86dee01b82SJohn Snowimport sysconfig
87dd84028fSJohn Snowfrom types import SimpleNamespace
88c5538eedSJohn Snowfrom typing import (
89c5538eedSJohn Snow    Any,
900f1ec070SPaolo Bonzini    Dict,
9192834894SJohn Snow    Iterator,
92c5538eedSJohn Snow    Optional,
93c5538eedSJohn Snow    Sequence,
944695a22eSJohn Snow    Tuple,
95c5538eedSJohn Snow    Union,
96c5538eedSJohn Snow)
97dd84028fSJohn Snowimport venv
98c5538eedSJohn Snow
9968ea6d17SJohn Snow
10068ea6d17SJohn Snow# Try to load distlib, with a fallback to pip's vendored version.
10168ea6d17SJohn Snow# HAVE_DISTLIB is checked below, just-in-time, so that mkvenv does not fail
10268ea6d17SJohn Snow# outside the venv or before a potential call to ensurepip in checkpip().
10368ea6d17SJohn SnowHAVE_DISTLIB = True
10468ea6d17SJohn Snowtry:
10592834894SJohn Snow    import distlib.scripts
106c5538eedSJohn Snow    import distlib.version
10768ea6d17SJohn Snowexcept ImportError:
10868ea6d17SJohn Snow    try:
10968ea6d17SJohn Snow        # Reach into pip's cookie jar.  pylint and flake8 don't understand
11068ea6d17SJohn Snow        # that these imports will be used via distlib.xxx.
11168ea6d17SJohn Snow        from pip._vendor import distlib
11268ea6d17SJohn Snow        import pip._vendor.distlib.scripts  # noqa, pylint: disable=unused-import
11368ea6d17SJohn Snow        import pip._vendor.distlib.version  # noqa, pylint: disable=unused-import
11468ea6d17SJohn Snow    except ImportError:
11568ea6d17SJohn Snow        HAVE_DISTLIB = False
116dd84028fSJohn Snow
117*71ed611cSPaolo Bonzini# Try to load tomllib, with a fallback to tomli.
118*71ed611cSPaolo Bonzini# HAVE_TOMLLIB is checked below, just-in-time, so that mkvenv does not fail
119*71ed611cSPaolo Bonzini# outside the venv or before a potential call to ensurepip in checkpip().
120*71ed611cSPaolo BonziniHAVE_TOMLLIB = True
121*71ed611cSPaolo Bonzinitry:
122*71ed611cSPaolo Bonzini    import tomllib
123*71ed611cSPaolo Bonziniexcept ImportError:
124*71ed611cSPaolo Bonzini    try:
125*71ed611cSPaolo Bonzini        import tomli as tomllib
126*71ed611cSPaolo Bonzini    except ImportError:
127*71ed611cSPaolo Bonzini        HAVE_TOMLLIB = False
128*71ed611cSPaolo Bonzini
129dd84028fSJohn Snow# Do not add any mandatory dependencies from outside the stdlib:
130dd84028fSJohn Snow# This script *must* be usable standalone!
131dd84028fSJohn Snow
132dd84028fSJohn SnowDirType = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"]
133dd84028fSJohn Snowlogger = logging.getLogger("mkvenv")
134dd84028fSJohn Snow
135dd84028fSJohn Snow
136dee01b82SJohn Snowdef inside_a_venv() -> bool:
137dee01b82SJohn Snow    """Returns True if it is executed inside of a virtual environment."""
138dee01b82SJohn Snow    return sys.prefix != sys.base_prefix
139dee01b82SJohn Snow
140dee01b82SJohn Snow
141dd84028fSJohn Snowclass Ouch(RuntimeError):
142dd84028fSJohn Snow    """An Exception class we can't confuse with a builtin."""
143dd84028fSJohn Snow
144dd84028fSJohn Snow
145dd84028fSJohn Snowclass QemuEnvBuilder(venv.EnvBuilder):
146dd84028fSJohn Snow    """
147dd84028fSJohn Snow    An extension of venv.EnvBuilder for building QEMU's configure-time venv.
148dd84028fSJohn Snow
149dee01b82SJohn Snow    The primary difference is that it emulates a "nested" virtual
150dee01b82SJohn Snow    environment when invoked from inside of an existing virtual
151f1ad527fSJohn Snow    environment by including packages from the parent.  Also,
152f1ad527fSJohn Snow    "ensurepip" is replaced if possible with just recreating pip's
153f1ad527fSJohn Snow    console_scripts inside the virtual environment.
154dd84028fSJohn Snow
155dd84028fSJohn Snow    Parameters for base class init:
156dd84028fSJohn Snow      - system_site_packages: bool = False
157dd84028fSJohn Snow      - clear: bool = False
158dd84028fSJohn Snow      - symlinks: bool = False
159dd84028fSJohn Snow      - upgrade: bool = False
160dd84028fSJohn Snow      - with_pip: bool = False
161dd84028fSJohn Snow      - prompt: Optional[str] = None
162dd84028fSJohn Snow      - upgrade_deps: bool = False             (Since 3.9)
163dd84028fSJohn Snow    """
164dd84028fSJohn Snow
165dd84028fSJohn Snow    def __init__(self, *args: Any, **kwargs: Any) -> None:
166dd84028fSJohn Snow        logger.debug("QemuEnvBuilder.__init__(...)")
167a9dbde71SJohn Snow
168dee01b82SJohn Snow        # For nested venv emulation:
169dee01b82SJohn Snow        self.use_parent_packages = False
170dee01b82SJohn Snow        if inside_a_venv():
171dee01b82SJohn Snow            # Include parent packages only if we're in a venv and
172dee01b82SJohn Snow            # system_site_packages was True.
173dee01b82SJohn Snow            self.use_parent_packages = kwargs.pop(
174dee01b82SJohn Snow                "system_site_packages", False
175dee01b82SJohn Snow            )
176dee01b82SJohn Snow            # Include system_site_packages only when the parent,
177dee01b82SJohn Snow            # The venv we are currently in, also does so.
178dee01b82SJohn Snow            kwargs["system_site_packages"] = sys.base_prefix in site.PREFIXES
179dee01b82SJohn Snow
180f1ad527fSJohn Snow        # ensurepip is slow: venv creation can be very fast for cases where
181f1ad527fSJohn Snow        # we allow the use of system_site_packages. Therefore, ensurepip is
182f1ad527fSJohn Snow        # replaced with our own script generation once the virtual environment
183f1ad527fSJohn Snow        # is setup.
184f1ad527fSJohn Snow        self.want_pip = kwargs.get("with_pip", False)
185f1ad527fSJohn Snow        if self.want_pip:
186f1ad527fSJohn Snow            if (
187f1ad527fSJohn Snow                kwargs.get("system_site_packages", False)
188f1ad527fSJohn Snow                and not need_ensurepip()
189f1ad527fSJohn Snow            ):
190f1ad527fSJohn Snow                kwargs["with_pip"] = False
191f1ad527fSJohn Snow            else:
192c8049626SJohn Snow                check_ensurepip(suggest_remedy=True)
193a9dbde71SJohn Snow
194dd84028fSJohn Snow        super().__init__(*args, **kwargs)
195dd84028fSJohn Snow
196dd84028fSJohn Snow        # Make the context available post-creation:
197dd84028fSJohn Snow        self._context: Optional[SimpleNamespace] = None
198dd84028fSJohn Snow
199dee01b82SJohn Snow    def get_parent_libpath(self) -> Optional[str]:
200dee01b82SJohn Snow        """Return the libpath of the parent venv, if applicable."""
201dee01b82SJohn Snow        if self.use_parent_packages:
202dee01b82SJohn Snow            return sysconfig.get_path("purelib")
203dee01b82SJohn Snow        return None
204dee01b82SJohn Snow
205dee01b82SJohn Snow    @staticmethod
206dee01b82SJohn Snow    def compute_venv_libpath(context: SimpleNamespace) -> str:
207dee01b82SJohn Snow        """
208dee01b82SJohn Snow        Compatibility wrapper for context.lib_path for Python < 3.12
209dee01b82SJohn Snow        """
210dee01b82SJohn Snow        # Python 3.12+, not strictly necessary because it's documented
211dee01b82SJohn Snow        # to be the same as 3.10 code below:
212dee01b82SJohn Snow        if sys.version_info >= (3, 12):
213dee01b82SJohn Snow            return context.lib_path
214dee01b82SJohn Snow
215dee01b82SJohn Snow        # Python 3.10+
216dee01b82SJohn Snow        if "venv" in sysconfig.get_scheme_names():
217dee01b82SJohn Snow            lib_path = sysconfig.get_path(
218dee01b82SJohn Snow                "purelib", scheme="venv", vars={"base": context.env_dir}
219dee01b82SJohn Snow            )
220dee01b82SJohn Snow            assert lib_path is not None
221dee01b82SJohn Snow            return lib_path
222dee01b82SJohn Snow
223dee01b82SJohn Snow        # For Python <= 3.9 we need to hardcode this. Fortunately the
224dee01b82SJohn Snow        # code below was the same in Python 3.6-3.10, so there is only
225dee01b82SJohn Snow        # one case.
226dee01b82SJohn Snow        if sys.platform == "win32":
227dee01b82SJohn Snow            return os.path.join(context.env_dir, "Lib", "site-packages")
228dee01b82SJohn Snow        return os.path.join(
229dee01b82SJohn Snow            context.env_dir,
230dee01b82SJohn Snow            "lib",
231dee01b82SJohn Snow            "python%d.%d" % sys.version_info[:2],
232dee01b82SJohn Snow            "site-packages",
233dee01b82SJohn Snow        )
234dee01b82SJohn Snow
235dd84028fSJohn Snow    def ensure_directories(self, env_dir: DirType) -> SimpleNamespace:
236dd84028fSJohn Snow        logger.debug("ensure_directories(env_dir=%s)", env_dir)
237dd84028fSJohn Snow        self._context = super().ensure_directories(env_dir)
238dd84028fSJohn Snow        return self._context
239dd84028fSJohn Snow
240dee01b82SJohn Snow    def create(self, env_dir: DirType) -> None:
241dee01b82SJohn Snow        logger.debug("create(env_dir=%s)", env_dir)
242dee01b82SJohn Snow        super().create(env_dir)
243dee01b82SJohn Snow        assert self._context is not None
244dee01b82SJohn Snow        self.post_post_setup(self._context)
245dee01b82SJohn Snow
246dee01b82SJohn Snow    def post_post_setup(self, context: SimpleNamespace) -> None:
247dee01b82SJohn Snow        """
248dee01b82SJohn Snow        The final, final hook. Enter the venv and run commands inside of it.
249dee01b82SJohn Snow        """
250dee01b82SJohn Snow        if self.use_parent_packages:
251dee01b82SJohn Snow            # We're inside of a venv and we want to include the parent
252dee01b82SJohn Snow            # venv's packages.
253dee01b82SJohn Snow            parent_libpath = self.get_parent_libpath()
254dee01b82SJohn Snow            assert parent_libpath is not None
255dee01b82SJohn Snow            logger.debug("parent_libpath: %s", parent_libpath)
256dee01b82SJohn Snow
257dee01b82SJohn Snow            our_libpath = self.compute_venv_libpath(context)
258dee01b82SJohn Snow            logger.debug("our_libpath: %s", our_libpath)
259dee01b82SJohn Snow
260dee01b82SJohn Snow            pth_file = os.path.join(our_libpath, "nested.pth")
261dee01b82SJohn Snow            with open(pth_file, "w", encoding="UTF-8") as file:
262dee01b82SJohn Snow                file.write(parent_libpath + os.linesep)
263dee01b82SJohn Snow
264f1ad527fSJohn Snow        if self.want_pip:
265f1ad527fSJohn Snow            args = [
266f1ad527fSJohn Snow                context.env_exe,
267f1ad527fSJohn Snow                __file__,
268f1ad527fSJohn Snow                "post_init",
269f1ad527fSJohn Snow            ]
270f1ad527fSJohn Snow            subprocess.run(args, check=True)
271f1ad527fSJohn Snow
272dd84028fSJohn Snow    def get_value(self, field: str) -> str:
273dd84028fSJohn Snow        """
274dd84028fSJohn Snow        Get a string value from the context namespace after a call to build.
275dd84028fSJohn Snow
276dd84028fSJohn Snow        For valid field names, see:
277dd84028fSJohn Snow        https://docs.python.org/3/library/venv.html#venv.EnvBuilder.ensure_directories
278dd84028fSJohn Snow        """
279dd84028fSJohn Snow        ret = getattr(self._context, field)
280dd84028fSJohn Snow        assert isinstance(ret, str)
281dd84028fSJohn Snow        return ret
282dd84028fSJohn Snow
283dd84028fSJohn Snow
284f1ad527fSJohn Snowdef need_ensurepip() -> bool:
285f1ad527fSJohn Snow    """
286f1ad527fSJohn Snow    Tests for the presence of setuptools and pip.
287f1ad527fSJohn Snow
288f1ad527fSJohn Snow    :return: `True` if we do not detect both packages.
289f1ad527fSJohn Snow    """
290f1ad527fSJohn Snow    # Don't try to actually import them, it's fraught with danger:
291f1ad527fSJohn Snow    # https://github.com/pypa/setuptools/issues/2993
292f1ad527fSJohn Snow    if find_spec("setuptools") and find_spec("pip"):
293f1ad527fSJohn Snow        return False
294f1ad527fSJohn Snow    return True
295f1ad527fSJohn Snow
296f1ad527fSJohn Snow
297c8049626SJohn Snowdef check_ensurepip(prefix: str = "", suggest_remedy: bool = False) -> None:
298a9dbde71SJohn Snow    """
299a9dbde71SJohn Snow    Check that we have ensurepip.
300a9dbde71SJohn Snow
301a9dbde71SJohn Snow    Raise a fatal exception with a helpful hint if it isn't available.
302a9dbde71SJohn Snow    """
303a9dbde71SJohn Snow    if not find_spec("ensurepip"):
304a9dbde71SJohn Snow        msg = (
305a9dbde71SJohn Snow            "Python's ensurepip module is not found.\n"
306a9dbde71SJohn Snow            "It's normally part of the Python standard library, "
307a9dbde71SJohn Snow            "maybe your distribution packages it separately?\n"
308c8049626SJohn Snow            "(Debian puts ensurepip in its python3-venv package.)\n"
309c8049626SJohn Snow        )
310c8049626SJohn Snow        if suggest_remedy:
311c8049626SJohn Snow            msg += (
312a9dbde71SJohn Snow                "Either install ensurepip, or alleviate the need for it in the"
313a9dbde71SJohn Snow                " first place by installing pip and setuptools for "
314a9dbde71SJohn Snow                f"'{sys.executable}'.\n"
315a9dbde71SJohn Snow            )
316c8049626SJohn Snow        raise Ouch(prefix + msg)
317a9dbde71SJohn Snow
318a9dbde71SJohn Snow    # ensurepip uses pyexpat, which can also go missing on us:
319a9dbde71SJohn Snow    if not find_spec("pyexpat"):
320a9dbde71SJohn Snow        msg = (
321a9dbde71SJohn Snow            "Python's pyexpat module is not found.\n"
322a9dbde71SJohn Snow            "It's normally part of the Python standard library, "
323a9dbde71SJohn Snow            "maybe your distribution packages it separately?\n"
324c8049626SJohn Snow            "(NetBSD's pkgsrc debundles this to e.g. 'py310-expat'.)\n"
325c8049626SJohn Snow        )
326c8049626SJohn Snow        if suggest_remedy:
327c8049626SJohn Snow            msg += (
328a9dbde71SJohn Snow                "Either install pyexpat, or alleviate the need for it in the "
329a9dbde71SJohn Snow                "first place by installing pip and setuptools for "
330c8049626SJohn Snow                f"'{sys.executable}'.\n"
331a9dbde71SJohn Snow            )
332c8049626SJohn Snow        raise Ouch(prefix + msg)
333a9dbde71SJohn Snow
334a9dbde71SJohn Snow
335dd84028fSJohn Snowdef make_venv(  # pylint: disable=too-many-arguments
336dd84028fSJohn Snow    env_dir: Union[str, Path],
337dd84028fSJohn Snow    system_site_packages: bool = False,
338dd84028fSJohn Snow    clear: bool = True,
339dd84028fSJohn Snow    symlinks: Optional[bool] = None,
340dd84028fSJohn Snow    with_pip: bool = True,
341dd84028fSJohn Snow) -> None:
342dd84028fSJohn Snow    """
343dd84028fSJohn Snow    Create a venv using `QemuEnvBuilder`.
344dd84028fSJohn Snow
345dd84028fSJohn Snow    This is analogous to the `venv.create` module-level convenience
346dd84028fSJohn Snow    function that is part of the Python stdblib, except it uses
347dd84028fSJohn Snow    `QemuEnvBuilder` instead.
348dd84028fSJohn Snow
349dd84028fSJohn Snow    :param env_dir: The directory to create/install to.
350dd84028fSJohn Snow    :param system_site_packages:
351dd84028fSJohn Snow        Allow inheriting packages from the system installation.
352dd84028fSJohn Snow    :param clear: When True, fully remove any prior venv and files.
353dd84028fSJohn Snow    :param symlinks:
354dd84028fSJohn Snow        Whether to use symlinks to the target interpreter or not. If
355dd84028fSJohn Snow        left unspecified, it will use symlinks except on Windows to
356dd84028fSJohn Snow        match behavior with the "venv" CLI tool.
357dd84028fSJohn Snow    :param with_pip:
358dd84028fSJohn Snow        Whether to install "pip" binaries or not.
359dd84028fSJohn Snow    """
360dd84028fSJohn Snow    logger.debug(
361dd84028fSJohn Snow        "%s: make_venv(env_dir=%s, system_site_packages=%s, "
362dd84028fSJohn Snow        "clear=%s, symlinks=%s, with_pip=%s)",
363dd84028fSJohn Snow        __file__,
364dd84028fSJohn Snow        str(env_dir),
365dd84028fSJohn Snow        system_site_packages,
366dd84028fSJohn Snow        clear,
367dd84028fSJohn Snow        symlinks,
368dd84028fSJohn Snow        with_pip,
369dd84028fSJohn Snow    )
370dd84028fSJohn Snow
371dd84028fSJohn Snow    if symlinks is None:
372dd84028fSJohn Snow        # Default behavior of standard venv CLI
373dd84028fSJohn Snow        symlinks = os.name != "nt"
374dd84028fSJohn Snow
375dd84028fSJohn Snow    builder = QemuEnvBuilder(
376dd84028fSJohn Snow        system_site_packages=system_site_packages,
377dd84028fSJohn Snow        clear=clear,
378dd84028fSJohn Snow        symlinks=symlinks,
379dd84028fSJohn Snow        with_pip=with_pip,
380dd84028fSJohn Snow    )
381dd84028fSJohn Snow
382dd84028fSJohn Snow    style = "non-isolated" if builder.system_site_packages else "isolated"
383dee01b82SJohn Snow    nested = ""
384dee01b82SJohn Snow    if builder.use_parent_packages:
385dee01b82SJohn Snow        nested = f"(with packages from '{builder.get_parent_libpath()}') "
386dd84028fSJohn Snow    print(
387dd84028fSJohn Snow        f"mkvenv: Creating {style} virtual environment"
388dee01b82SJohn Snow        f" {nested}at '{str(env_dir)}'",
389dd84028fSJohn Snow        file=sys.stderr,
390dd84028fSJohn Snow    )
391dd84028fSJohn Snow
392dd84028fSJohn Snow    try:
393dd84028fSJohn Snow        logger.debug("Invoking builder.create()")
394dd84028fSJohn Snow        try:
395dd84028fSJohn Snow            builder.create(str(env_dir))
396dd84028fSJohn Snow        except SystemExit as exc:
397dd84028fSJohn Snow            # Some versions of the venv module raise SystemExit; *nasty*!
398dd84028fSJohn Snow            # We want the exception that prompted it. It might be a subprocess
399dd84028fSJohn Snow            # error that has output we *really* want to see.
400dd84028fSJohn Snow            logger.debug("Intercepted SystemExit from EnvBuilder.create()")
401dd84028fSJohn Snow            raise exc.__cause__ or exc.__context__ or exc
402dd84028fSJohn Snow        logger.debug("builder.create() finished")
403dd84028fSJohn Snow    except subprocess.CalledProcessError as exc:
404dd84028fSJohn Snow        logger.error("mkvenv subprocess failed:")
405dd84028fSJohn Snow        logger.error("cmd: %s", exc.cmd)
406dd84028fSJohn Snow        logger.error("returncode: %d", exc.returncode)
407dd84028fSJohn Snow
408dd84028fSJohn Snow        def _stringify(data: Union[str, bytes]) -> str:
409dd84028fSJohn Snow            if isinstance(data, bytes):
410dd84028fSJohn Snow                return data.decode()
411dd84028fSJohn Snow            return data
412dd84028fSJohn Snow
413dd84028fSJohn Snow        lines = []
414dd84028fSJohn Snow        if exc.stdout:
415dd84028fSJohn Snow            lines.append("========== stdout ==========")
416dd84028fSJohn Snow            lines.append(_stringify(exc.stdout))
417dd84028fSJohn Snow            lines.append("============================")
418dd84028fSJohn Snow        if exc.stderr:
419dd84028fSJohn Snow            lines.append("========== stderr ==========")
420dd84028fSJohn Snow            lines.append(_stringify(exc.stderr))
421dd84028fSJohn Snow            lines.append("============================")
422dd84028fSJohn Snow        if lines:
423dd84028fSJohn Snow            logger.error(os.linesep.join(lines))
424dd84028fSJohn Snow
425dd84028fSJohn Snow        raise Ouch("VENV creation subprocess failed.") from exc
426dd84028fSJohn Snow
427dd84028fSJohn Snow    # print the python executable to stdout for configure.
428dd84028fSJohn Snow    print(builder.get_value("env_exe"))
429dd84028fSJohn Snow
430dd84028fSJohn Snow
43192834894SJohn Snowdef _gen_importlib(packages: Sequence[str]) -> Iterator[str]:
43292834894SJohn Snow    # pylint: disable=import-outside-toplevel
43392834894SJohn Snow    # pylint: disable=no-name-in-module
43492834894SJohn Snow    # pylint: disable=import-error
43592834894SJohn Snow    try:
43692834894SJohn Snow        # First preference: Python 3.8+ stdlib
43792834894SJohn Snow        from importlib.metadata import (  # type: ignore
43892834894SJohn Snow            PackageNotFoundError,
43992834894SJohn Snow            distribution,
44092834894SJohn Snow        )
44192834894SJohn Snow    except ImportError as exc:
44292834894SJohn Snow        logger.debug("%s", str(exc))
44392834894SJohn Snow        # Second preference: Commonly available PyPI backport
44492834894SJohn Snow        from importlib_metadata import (  # type: ignore
44592834894SJohn Snow            PackageNotFoundError,
44692834894SJohn Snow            distribution,
44792834894SJohn Snow        )
44892834894SJohn Snow
44992834894SJohn Snow    def _generator() -> Iterator[str]:
45092834894SJohn Snow        for package in packages:
45192834894SJohn Snow            try:
45292834894SJohn Snow                entry_points = distribution(package).entry_points
45392834894SJohn Snow            except PackageNotFoundError:
45492834894SJohn Snow                continue
45592834894SJohn Snow
45692834894SJohn Snow            # The EntryPoints type is only available in 3.10+,
45792834894SJohn Snow            # treat this as a vanilla list and filter it ourselves.
45892834894SJohn Snow            entry_points = filter(
45992834894SJohn Snow                lambda ep: ep.group == "console_scripts", entry_points
46092834894SJohn Snow            )
46192834894SJohn Snow
46292834894SJohn Snow            for entry_point in entry_points:
46392834894SJohn Snow                yield f"{entry_point.name} = {entry_point.value}"
46492834894SJohn Snow
46592834894SJohn Snow    return _generator()
46692834894SJohn Snow
46792834894SJohn Snow
46892834894SJohn Snowdef _gen_pkg_resources(packages: Sequence[str]) -> Iterator[str]:
46992834894SJohn Snow    # pylint: disable=import-outside-toplevel
47092834894SJohn Snow    # Bundled with setuptools; has a good chance of being available.
47192834894SJohn Snow    import pkg_resources
47292834894SJohn Snow
47392834894SJohn Snow    def _generator() -> Iterator[str]:
47492834894SJohn Snow        for package in packages:
47592834894SJohn Snow            try:
47692834894SJohn Snow                eps = pkg_resources.get_entry_map(package, "console_scripts")
47792834894SJohn Snow            except pkg_resources.DistributionNotFound:
47892834894SJohn Snow                continue
47992834894SJohn Snow
48092834894SJohn Snow            for entry_point in eps.values():
48192834894SJohn Snow                yield str(entry_point)
48292834894SJohn Snow
48392834894SJohn Snow    return _generator()
48492834894SJohn Snow
48592834894SJohn Snow
48692834894SJohn Snowdef generate_console_scripts(
48792834894SJohn Snow    packages: Sequence[str],
48892834894SJohn Snow    python_path: Optional[str] = None,
48992834894SJohn Snow    bin_path: Optional[str] = None,
49092834894SJohn Snow) -> None:
49192834894SJohn Snow    """
49292834894SJohn Snow    Generate script shims for console_script entry points in @packages.
49392834894SJohn Snow    """
49492834894SJohn Snow    if python_path is None:
49592834894SJohn Snow        python_path = sys.executable
49692834894SJohn Snow    if bin_path is None:
49792834894SJohn Snow        bin_path = sysconfig.get_path("scripts")
49892834894SJohn Snow        assert bin_path is not None
49992834894SJohn Snow
50092834894SJohn Snow    logger.debug(
50192834894SJohn Snow        "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)",
50292834894SJohn Snow        packages,
50392834894SJohn Snow        python_path,
50492834894SJohn Snow        bin_path,
50592834894SJohn Snow    )
50692834894SJohn Snow
50792834894SJohn Snow    if not packages:
50892834894SJohn Snow        return
50992834894SJohn Snow
51092834894SJohn Snow    def _get_entry_points() -> Iterator[str]:
51192834894SJohn Snow        """Python 3.7 compatibility shim for iterating entry points."""
51292834894SJohn Snow        # Python 3.8+, or Python 3.7 with importlib_metadata installed.
51392834894SJohn Snow        try:
51492834894SJohn Snow            return _gen_importlib(packages)
51592834894SJohn Snow        except ImportError as exc:
51692834894SJohn Snow            logger.debug("%s", str(exc))
51792834894SJohn Snow
51892834894SJohn Snow        # Python 3.7 with setuptools installed.
51992834894SJohn Snow        try:
52092834894SJohn Snow            return _gen_pkg_resources(packages)
52192834894SJohn Snow        except ImportError as exc:
52292834894SJohn Snow            logger.debug("%s", str(exc))
52392834894SJohn Snow            raise Ouch(
52492834894SJohn Snow                "Neither importlib.metadata nor pkg_resources found, "
52592834894SJohn Snow                "can't generate console script shims.\n"
52692834894SJohn Snow                "Use Python 3.8+, or install importlib-metadata or setuptools."
52792834894SJohn Snow            ) from exc
52892834894SJohn Snow
52992834894SJohn Snow    maker = distlib.scripts.ScriptMaker(None, bin_path)
53092834894SJohn Snow    maker.variants = {""}
53192834894SJohn Snow    maker.clobber = False
53292834894SJohn Snow
53392834894SJohn Snow    for entry_point in _get_entry_points():
53492834894SJohn Snow        for filename in maker.make(entry_point):
53592834894SJohn Snow            logger.debug("wrote console_script '%s'", filename)
53692834894SJohn Snow
53792834894SJohn Snow
538c8049626SJohn Snowdef checkpip() -> bool:
539c8049626SJohn Snow    """
540c8049626SJohn Snow    Debian10 has a pip that's broken when used inside of a virtual environment.
541c8049626SJohn Snow
542c8049626SJohn Snow    We try to detect and correct that case here.
543c8049626SJohn Snow    """
544c8049626SJohn Snow    try:
545c8049626SJohn Snow        # pylint: disable=import-outside-toplevel,unused-import,import-error
546c8049626SJohn Snow        # pylint: disable=redefined-outer-name
547c8049626SJohn Snow        import pip._internal  # type: ignore  # noqa: F401
548c8049626SJohn Snow
549c8049626SJohn Snow        logger.debug("pip appears to be working correctly.")
550c8049626SJohn Snow        return False
551c8049626SJohn Snow    except ModuleNotFoundError as exc:
552c8049626SJohn Snow        if exc.name == "pip._internal":
553c8049626SJohn Snow            # Uh, fair enough. They did say "internal".
554c8049626SJohn Snow            # Let's just assume it's fine.
555c8049626SJohn Snow            return False
556c8049626SJohn Snow        logger.warning("pip appears to be malfunctioning: %s", str(exc))
557c8049626SJohn Snow
558c8049626SJohn Snow    check_ensurepip("pip appears to be non-functional, and ")
559c8049626SJohn Snow
560c8049626SJohn Snow    logger.debug("Attempting to repair pip ...")
561c8049626SJohn Snow    subprocess.run(
562c8049626SJohn Snow        (sys.executable, "-m", "ensurepip"),
563c8049626SJohn Snow        stdout=subprocess.DEVNULL,
564c8049626SJohn Snow        check=True,
565c8049626SJohn Snow    )
566c8049626SJohn Snow    logger.debug("Pip is now (hopefully) repaired!")
567c8049626SJohn Snow    return True
568c8049626SJohn Snow
569c8049626SJohn Snow
5704695a22eSJohn Snowdef pkgname_from_depspec(dep_spec: str) -> str:
5714695a22eSJohn Snow    """
5724695a22eSJohn Snow    Parse package name out of a PEP-508 depspec.
5734695a22eSJohn Snow
5744695a22eSJohn Snow    See https://peps.python.org/pep-0508/#names
5754695a22eSJohn Snow    """
5764695a22eSJohn Snow    match = re.match(
5774695a22eSJohn Snow        r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE
5784695a22eSJohn Snow    )
5794695a22eSJohn Snow    if not match:
5804695a22eSJohn Snow        raise ValueError(
5814695a22eSJohn Snow            f"dep_spec '{dep_spec}'"
5824695a22eSJohn Snow            " does not appear to contain a valid package name"
5834695a22eSJohn Snow        )
5844695a22eSJohn Snow    return match.group(0)
5854695a22eSJohn Snow
5864695a22eSJohn Snow
58747a90a51SPaolo Bonzinidef _get_path_importlib(package: str) -> Optional[str]:
58847a90a51SPaolo Bonzini    # pylint: disable=import-outside-toplevel
58947a90a51SPaolo Bonzini    # pylint: disable=no-name-in-module
59047a90a51SPaolo Bonzini    # pylint: disable=import-error
59147a90a51SPaolo Bonzini    try:
59247a90a51SPaolo Bonzini        # First preference: Python 3.8+ stdlib
59347a90a51SPaolo Bonzini        from importlib.metadata import (  # type: ignore
59447a90a51SPaolo Bonzini            PackageNotFoundError,
59547a90a51SPaolo Bonzini            distribution,
59647a90a51SPaolo Bonzini        )
59747a90a51SPaolo Bonzini    except ImportError as exc:
59847a90a51SPaolo Bonzini        logger.debug("%s", str(exc))
59947a90a51SPaolo Bonzini        # Second preference: Commonly available PyPI backport
60047a90a51SPaolo Bonzini        from importlib_metadata import (  # type: ignore
60147a90a51SPaolo Bonzini            PackageNotFoundError,
60247a90a51SPaolo Bonzini            distribution,
60347a90a51SPaolo Bonzini        )
60447a90a51SPaolo Bonzini
60547a90a51SPaolo Bonzini    try:
60647a90a51SPaolo Bonzini        return str(distribution(package).locate_file("."))
60747a90a51SPaolo Bonzini    except PackageNotFoundError:
60847a90a51SPaolo Bonzini        return None
60947a90a51SPaolo Bonzini
61047a90a51SPaolo Bonzini
61147a90a51SPaolo Bonzinidef _get_path_pkg_resources(package: str) -> Optional[str]:
61247a90a51SPaolo Bonzini    # pylint: disable=import-outside-toplevel
61347a90a51SPaolo Bonzini    # Bundled with setuptools; has a good chance of being available.
61447a90a51SPaolo Bonzini    import pkg_resources
61547a90a51SPaolo Bonzini
61647a90a51SPaolo Bonzini    try:
61747a90a51SPaolo Bonzini        return str(pkg_resources.get_distribution(package).location)
61847a90a51SPaolo Bonzini    except pkg_resources.DistributionNotFound:
61947a90a51SPaolo Bonzini        return None
62047a90a51SPaolo Bonzini
62147a90a51SPaolo Bonzini
62247a90a51SPaolo Bonzinidef _get_path(package: str) -> Optional[str]:
62347a90a51SPaolo Bonzini    try:
62447a90a51SPaolo Bonzini        return _get_path_importlib(package)
62547a90a51SPaolo Bonzini    except ImportError as exc:
62647a90a51SPaolo Bonzini        logger.debug("%s", str(exc))
62747a90a51SPaolo Bonzini
62847a90a51SPaolo Bonzini    try:
62947a90a51SPaolo Bonzini        return _get_path_pkg_resources(package)
63047a90a51SPaolo Bonzini    except ImportError as exc:
63147a90a51SPaolo Bonzini        logger.debug("%s", str(exc))
63247a90a51SPaolo Bonzini        raise Ouch(
63347a90a51SPaolo Bonzini            "Neither importlib.metadata nor pkg_resources found. "
63447a90a51SPaolo Bonzini            "Use Python 3.8+, or install importlib-metadata or setuptools."
63547a90a51SPaolo Bonzini        ) from exc
63647a90a51SPaolo Bonzini
63747a90a51SPaolo Bonzini
63847a90a51SPaolo Bonzinidef _path_is_prefix(prefix: Optional[str], path: str) -> bool:
63947a90a51SPaolo Bonzini    try:
64047a90a51SPaolo Bonzini        return (
64147a90a51SPaolo Bonzini            prefix is not None and os.path.commonpath([prefix, path]) == prefix
64247a90a51SPaolo Bonzini        )
64347a90a51SPaolo Bonzini    except ValueError:
64447a90a51SPaolo Bonzini        return False
64547a90a51SPaolo Bonzini
64647a90a51SPaolo Bonzini
64747a90a51SPaolo Bonzinidef _is_system_package(package: str) -> bool:
64847a90a51SPaolo Bonzini    path = _get_path(package)
64947a90a51SPaolo Bonzini    return path is not None and not (
65047a90a51SPaolo Bonzini        _path_is_prefix(sysconfig.get_path("purelib"), path)
65147a90a51SPaolo Bonzini        or _path_is_prefix(sysconfig.get_path("platlib"), path)
65247a90a51SPaolo Bonzini    )
65347a90a51SPaolo Bonzini
65447a90a51SPaolo Bonzini
655c673f3d0SPaolo Bonzinidef _get_version_importlib(package: str) -> Optional[str]:
656c673f3d0SPaolo Bonzini    # pylint: disable=import-outside-toplevel
657c673f3d0SPaolo Bonzini    # pylint: disable=no-name-in-module
658c673f3d0SPaolo Bonzini    # pylint: disable=import-error
659c673f3d0SPaolo Bonzini    try:
660c673f3d0SPaolo Bonzini        # First preference: Python 3.8+ stdlib
661c673f3d0SPaolo Bonzini        from importlib.metadata import (  # type: ignore
662c673f3d0SPaolo Bonzini            PackageNotFoundError,
663c673f3d0SPaolo Bonzini            distribution,
664c673f3d0SPaolo Bonzini        )
665c673f3d0SPaolo Bonzini    except ImportError as exc:
666c673f3d0SPaolo Bonzini        logger.debug("%s", str(exc))
667c673f3d0SPaolo Bonzini        # Second preference: Commonly available PyPI backport
668c673f3d0SPaolo Bonzini        from importlib_metadata import (  # type: ignore
669c673f3d0SPaolo Bonzini            PackageNotFoundError,
670c673f3d0SPaolo Bonzini            distribution,
671c673f3d0SPaolo Bonzini        )
672c673f3d0SPaolo Bonzini
673c673f3d0SPaolo Bonzini    try:
674c673f3d0SPaolo Bonzini        return str(distribution(package).version)
675c673f3d0SPaolo Bonzini    except PackageNotFoundError:
676c673f3d0SPaolo Bonzini        return None
677c673f3d0SPaolo Bonzini
678c673f3d0SPaolo Bonzini
679c673f3d0SPaolo Bonzinidef _get_version_pkg_resources(package: str) -> Optional[str]:
680c673f3d0SPaolo Bonzini    # pylint: disable=import-outside-toplevel
681c673f3d0SPaolo Bonzini    # Bundled with setuptools; has a good chance of being available.
682c673f3d0SPaolo Bonzini    import pkg_resources
683c673f3d0SPaolo Bonzini
684c673f3d0SPaolo Bonzini    try:
685c673f3d0SPaolo Bonzini        return str(pkg_resources.get_distribution(package).version)
686c673f3d0SPaolo Bonzini    except pkg_resources.DistributionNotFound:
687c673f3d0SPaolo Bonzini        return None
688c673f3d0SPaolo Bonzini
689c673f3d0SPaolo Bonzini
690c673f3d0SPaolo Bonzinidef _get_version(package: str) -> Optional[str]:
691c673f3d0SPaolo Bonzini    try:
692c673f3d0SPaolo Bonzini        return _get_version_importlib(package)
693c673f3d0SPaolo Bonzini    except ImportError as exc:
694c673f3d0SPaolo Bonzini        logger.debug("%s", str(exc))
695c673f3d0SPaolo Bonzini
696c673f3d0SPaolo Bonzini    try:
697c673f3d0SPaolo Bonzini        return _get_version_pkg_resources(package)
698c673f3d0SPaolo Bonzini    except ImportError as exc:
699c673f3d0SPaolo Bonzini        logger.debug("%s", str(exc))
700c673f3d0SPaolo Bonzini        raise Ouch(
701c673f3d0SPaolo Bonzini            "Neither importlib.metadata nor pkg_resources found. "
702c673f3d0SPaolo Bonzini            "Use Python 3.8+, or install importlib-metadata or setuptools."
703c673f3d0SPaolo Bonzini        ) from exc
704c673f3d0SPaolo Bonzini
705c673f3d0SPaolo Bonzini
7064695a22eSJohn Snowdef diagnose(
7074695a22eSJohn Snow    dep_spec: str,
7084695a22eSJohn Snow    online: bool,
7094695a22eSJohn Snow    wheels_dir: Optional[Union[str, Path]],
7104695a22eSJohn Snow    prog: Optional[str],
7114695a22eSJohn Snow) -> Tuple[str, bool]:
7124695a22eSJohn Snow    """
7134695a22eSJohn Snow    Offer a summary to the user as to why a package failed to be installed.
7144695a22eSJohn Snow
7154695a22eSJohn Snow    :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5'
7164695a22eSJohn Snow    :param online: Did we allow PyPI access?
7174695a22eSJohn Snow    :param prog:
7184695a22eSJohn Snow        Optionally, a shell program name that can be used as a
7194695a22eSJohn Snow        bellwether to detect if this program is installed elsewhere on
7204695a22eSJohn Snow        the system. This is used to offer advice when a program is
7214695a22eSJohn Snow        detected for a different python version.
7224695a22eSJohn Snow    :param wheels_dir:
7234695a22eSJohn Snow        Optionally, a directory that was searched for vendored packages.
7244695a22eSJohn Snow    """
7254695a22eSJohn Snow    # pylint: disable=too-many-branches
7264695a22eSJohn Snow
7274695a22eSJohn Snow    # Some errors are not particularly serious
7284695a22eSJohn Snow    bad = False
7294695a22eSJohn Snow
7304695a22eSJohn Snow    pkg_name = pkgname_from_depspec(dep_spec)
731c673f3d0SPaolo Bonzini    pkg_version = _get_version(pkg_name)
7324695a22eSJohn Snow
7334695a22eSJohn Snow    lines = []
7344695a22eSJohn Snow
7354695a22eSJohn Snow    if pkg_version:
7364695a22eSJohn Snow        lines.append(
7374695a22eSJohn Snow            f"Python package '{pkg_name}' version '{pkg_version}' was found,"
7384695a22eSJohn Snow            " but isn't suitable."
7394695a22eSJohn Snow        )
7404695a22eSJohn Snow    else:
7414695a22eSJohn Snow        lines.append(
742c673f3d0SPaolo Bonzini            f"Python package '{pkg_name}' was not found nor installed."
7434695a22eSJohn Snow        )
7444695a22eSJohn Snow
7454695a22eSJohn Snow    if wheels_dir:
7464695a22eSJohn Snow        lines.append(
7474695a22eSJohn Snow            "No suitable version found in, or failed to install from"
7484695a22eSJohn Snow            f" '{wheels_dir}'."
7494695a22eSJohn Snow        )
7504695a22eSJohn Snow        bad = True
7514695a22eSJohn Snow
7524695a22eSJohn Snow    if online:
7534695a22eSJohn Snow        lines.append("A suitable version could not be obtained from PyPI.")
7544695a22eSJohn Snow        bad = True
7554695a22eSJohn Snow    else:
7564695a22eSJohn Snow        lines.append(
7574695a22eSJohn Snow            "mkvenv was configured to operate offline and did not check PyPI."
7584695a22eSJohn Snow        )
7594695a22eSJohn Snow
7604695a22eSJohn Snow    if prog and not pkg_version:
7614695a22eSJohn Snow        which = shutil.which(prog)
7624695a22eSJohn Snow        if which:
7634695a22eSJohn Snow            if sys.base_prefix in site.PREFIXES:
7644695a22eSJohn Snow                pypath = Path(sys.executable).resolve()
7654695a22eSJohn Snow                lines.append(
7664695a22eSJohn Snow                    f"'{prog}' was detected on your system at '{which}', "
7674695a22eSJohn Snow                    f"but the Python package '{pkg_name}' was not found by "
7684695a22eSJohn Snow                    f"this Python interpreter ('{pypath}'). "
7694695a22eSJohn Snow                    f"Typically this means that '{prog}' has been installed "
7704695a22eSJohn Snow                    "against a different Python interpreter on your system."
7714695a22eSJohn Snow                )
7724695a22eSJohn Snow            else:
7734695a22eSJohn Snow                lines.append(
7744695a22eSJohn Snow                    f"'{prog}' was detected on your system at '{which}', "
7754695a22eSJohn Snow                    "but the build is using an isolated virtual environment."
7764695a22eSJohn Snow                )
7774695a22eSJohn Snow            bad = True
7784695a22eSJohn Snow
7794695a22eSJohn Snow    lines = [f" • {line}" for line in lines]
7804695a22eSJohn Snow    if bad:
7814695a22eSJohn Snow        lines.insert(0, f"Could not provide build dependency '{dep_spec}':")
7824695a22eSJohn Snow    else:
7834695a22eSJohn Snow        lines.insert(0, f"'{dep_spec}' not found:")
7844695a22eSJohn Snow    return os.linesep.join(lines), bad
7854695a22eSJohn Snow
7864695a22eSJohn Snow
787c5538eedSJohn Snowdef pip_install(
788c5538eedSJohn Snow    args: Sequence[str],
789c5538eedSJohn Snow    online: bool = False,
790c5538eedSJohn Snow    wheels_dir: Optional[Union[str, Path]] = None,
791c5538eedSJohn Snow) -> None:
792c5538eedSJohn Snow    """
793c5538eedSJohn Snow    Use pip to install a package or package(s) as specified in @args.
794c5538eedSJohn Snow    """
795c5538eedSJohn Snow    loud = bool(
796c5538eedSJohn Snow        os.environ.get("DEBUG")
797c5538eedSJohn Snow        or os.environ.get("GITLAB_CI")
798c5538eedSJohn Snow        or os.environ.get("V")
799c5538eedSJohn Snow    )
800c5538eedSJohn Snow
801c5538eedSJohn Snow    full_args = [
802c5538eedSJohn Snow        sys.executable,
803c5538eedSJohn Snow        "-m",
804c5538eedSJohn Snow        "pip",
805c5538eedSJohn Snow        "install",
806c5538eedSJohn Snow        "--disable-pip-version-check",
807c5538eedSJohn Snow        "-v" if loud else "-q",
808c5538eedSJohn Snow    ]
809c5538eedSJohn Snow    if not online:
810c5538eedSJohn Snow        full_args += ["--no-index"]
811c5538eedSJohn Snow    if wheels_dir:
812c5538eedSJohn Snow        full_args += ["--find-links", f"file://{str(wheels_dir)}"]
813c5538eedSJohn Snow    full_args += list(args)
814c5538eedSJohn Snow    subprocess.run(
815c5538eedSJohn Snow        full_args,
816c5538eedSJohn Snow        check=True,
817c5538eedSJohn Snow    )
818c5538eedSJohn Snow
819c5538eedSJohn Snow
8200f1ec070SPaolo Bonzinidef _make_version_constraint(info: Dict[str, str], install: bool) -> str:
8210f1ec070SPaolo Bonzini    """
8220f1ec070SPaolo Bonzini    Construct the version constraint part of a PEP 508 dependency
8230f1ec070SPaolo Bonzini    specification (for example '>=0.61.5') from the accepted and
8240f1ec070SPaolo Bonzini    installed keys of the provided dictionary.
8250f1ec070SPaolo Bonzini
8260f1ec070SPaolo Bonzini    :param info: A dictionary corresponding to a TOML key-value list.
8270f1ec070SPaolo Bonzini    :param install: True generates install constraints, False generates
8280f1ec070SPaolo Bonzini        presence constraints
8290f1ec070SPaolo Bonzini    """
8300f1ec070SPaolo Bonzini    if install and "installed" in info:
8310f1ec070SPaolo Bonzini        return "==" + info["installed"]
8320f1ec070SPaolo Bonzini
8330f1ec070SPaolo Bonzini    dep_spec = info.get("accepted", "")
8340f1ec070SPaolo Bonzini    dep_spec = dep_spec.strip()
8350f1ec070SPaolo Bonzini    # Double check that they didn't just use a version number
8360f1ec070SPaolo Bonzini    if dep_spec and dep_spec[0] not in "!~><=(":
8370f1ec070SPaolo Bonzini        raise Ouch(
8380f1ec070SPaolo Bonzini            "invalid dependency specifier " + dep_spec + " in dependency file"
8390f1ec070SPaolo Bonzini        )
8400f1ec070SPaolo Bonzini
8410f1ec070SPaolo Bonzini    return dep_spec
8420f1ec070SPaolo Bonzini
8430f1ec070SPaolo Bonzini
8444695a22eSJohn Snowdef _do_ensure(
8450f1ec070SPaolo Bonzini    group: Dict[str, Dict[str, str]],
846c5538eedSJohn Snow    online: bool = False,
847c5538eedSJohn Snow    wheels_dir: Optional[Union[str, Path]] = None,
848d37c21b5SPaolo Bonzini) -> Optional[Tuple[str, bool]]:
849c5538eedSJohn Snow    """
8500f1ec070SPaolo Bonzini    Use pip to ensure we have the packages specified in @group.
851c5538eedSJohn Snow
8520f1ec070SPaolo Bonzini    If the packages are already installed, do nothing. If online and
853c5538eedSJohn Snow    wheels_dir are both provided, prefer packages found in wheels_dir
854c5538eedSJohn Snow    first before connecting to PyPI.
855c5538eedSJohn Snow
8560f1ec070SPaolo Bonzini    :param group: A dictionary of dictionaries, corresponding to a
8570f1ec070SPaolo Bonzini        section in a pythondeps.toml file.
858c5538eedSJohn Snow    :param online: If True, fall back to PyPI.
859c5538eedSJohn Snow    :param wheels_dir: If specified, search this path for packages.
860c5538eedSJohn Snow    """
861c5538eedSJohn Snow    absent = []
86292834894SJohn Snow    present = []
86367b9a83dSPaolo Bonzini    canary = None
8640f1ec070SPaolo Bonzini    for name, info in group.items():
8650f1ec070SPaolo Bonzini        constraint = _make_version_constraint(info, False)
8660f1ec070SPaolo Bonzini        matcher = distlib.version.LegacyMatcher(name + constraint)
867*71ed611cSPaolo Bonzini        print(f"mkvenv: checking for {matcher}", file=sys.stderr)
8680f1ec070SPaolo Bonzini        ver = _get_version(name)
86947a90a51SPaolo Bonzini        if (
87047a90a51SPaolo Bonzini            ver is None
87147a90a51SPaolo Bonzini            # Always pass installed package to pip, so that they can be
87247a90a51SPaolo Bonzini            # updated if the requested version changes
8730f1ec070SPaolo Bonzini            or not _is_system_package(name)
87447a90a51SPaolo Bonzini            or not matcher.match(distlib.version.LegacyVersion(ver))
875c673f3d0SPaolo Bonzini        ):
8760f1ec070SPaolo Bonzini            absent.append(name + _make_version_constraint(info, True))
8770f1ec070SPaolo Bonzini            if len(absent) == 1:
8780f1ec070SPaolo Bonzini                canary = info.get("canary", None)
879c5538eedSJohn Snow        else:
8800f1ec070SPaolo Bonzini            logger.info("found %s %s", name, ver)
8810f1ec070SPaolo Bonzini            present.append(name)
88292834894SJohn Snow
88392834894SJohn Snow    if present:
88492834894SJohn Snow        generate_console_scripts(present)
885c5538eedSJohn Snow
886c5538eedSJohn Snow    if absent:
887d37c21b5SPaolo Bonzini        if online or wheels_dir:
888c5538eedSJohn Snow            # Some packages are missing or aren't a suitable version,
889c5538eedSJohn Snow            # install a suitable (possibly vendored) package.
890c5538eedSJohn Snow            print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr)
891d37c21b5SPaolo Bonzini            try:
892c5538eedSJohn Snow                pip_install(args=absent, online=online, wheels_dir=wheels_dir)
893d37c21b5SPaolo Bonzini                return None
894d37c21b5SPaolo Bonzini            except subprocess.CalledProcessError:
895d37c21b5SPaolo Bonzini                pass
896d37c21b5SPaolo Bonzini
897d37c21b5SPaolo Bonzini        return diagnose(
898d37c21b5SPaolo Bonzini            absent[0],
899d37c21b5SPaolo Bonzini            online,
900d37c21b5SPaolo Bonzini            wheels_dir,
90167b9a83dSPaolo Bonzini            canary,
902d37c21b5SPaolo Bonzini        )
903d37c21b5SPaolo Bonzini
904d37c21b5SPaolo Bonzini    return None
905c5538eedSJohn Snow
906c5538eedSJohn Snow
9074695a22eSJohn Snowdef ensure(
9084695a22eSJohn Snow    dep_specs: Sequence[str],
9094695a22eSJohn Snow    online: bool = False,
9104695a22eSJohn Snow    wheels_dir: Optional[Union[str, Path]] = None,
9114695a22eSJohn Snow    prog: Optional[str] = None,
9124695a22eSJohn Snow) -> None:
9134695a22eSJohn Snow    """
9144695a22eSJohn Snow    Use pip to ensure we have the package specified by @dep_specs.
9154695a22eSJohn Snow
9164695a22eSJohn Snow    If the package is already installed, do nothing. If online and
9174695a22eSJohn Snow    wheels_dir are both provided, prefer packages found in wheels_dir
9184695a22eSJohn Snow    first before connecting to PyPI.
9194695a22eSJohn Snow
9204695a22eSJohn Snow    :param dep_specs:
9214695a22eSJohn Snow        PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
9224695a22eSJohn Snow    :param online: If True, fall back to PyPI.
9234695a22eSJohn Snow    :param wheels_dir: If specified, search this path for packages.
9244695a22eSJohn Snow    :param prog:
9254695a22eSJohn Snow        If specified, use this program name for error diagnostics that will
9264695a22eSJohn Snow        be presented to the user. e.g., 'sphinx-build' can be used as a
9274695a22eSJohn Snow        bellwether for the presence of 'sphinx'.
9284695a22eSJohn Snow    """
92968ea6d17SJohn Snow
93068ea6d17SJohn Snow    if not HAVE_DISTLIB:
93168ea6d17SJohn Snow        raise Ouch("a usable distlib could not be found, please install it")
93268ea6d17SJohn Snow
9330f1ec070SPaolo Bonzini    # Convert the depspecs to a dictionary, as if they came
9340f1ec070SPaolo Bonzini    # from a section in a pythondeps.toml file
9350f1ec070SPaolo Bonzini    group: Dict[str, Dict[str, str]] = {}
9360f1ec070SPaolo Bonzini    for spec in dep_specs:
9370f1ec070SPaolo Bonzini        name = distlib.version.LegacyMatcher(spec).name
9380f1ec070SPaolo Bonzini        group[name] = {}
9390f1ec070SPaolo Bonzini
9400f1ec070SPaolo Bonzini        spec = spec.strip()
9410f1ec070SPaolo Bonzini        pos = len(name)
9420f1ec070SPaolo Bonzini        ver = spec[pos:].strip()
9430f1ec070SPaolo Bonzini        if ver:
9440f1ec070SPaolo Bonzini            group[name]["accepted"] = ver
9450f1ec070SPaolo Bonzini
9460f1ec070SPaolo Bonzini        if prog:
9470f1ec070SPaolo Bonzini            group[name]["canary"] = prog
9480f1ec070SPaolo Bonzini            prog = None
9490f1ec070SPaolo Bonzini
9500f1ec070SPaolo Bonzini    result = _do_ensure(group, online, wheels_dir)
951d37c21b5SPaolo Bonzini    if result:
9524695a22eSJohn Snow        # Well, that's not good.
953d37c21b5SPaolo Bonzini        if result[1]:
954d37c21b5SPaolo Bonzini            raise Ouch(result[0])
955d37c21b5SPaolo Bonzini        raise SystemExit(f"\n{result[0]}\n\n")
9564695a22eSJohn Snow
9574695a22eSJohn Snow
958*71ed611cSPaolo Bonzinidef _parse_groups(file: str) -> Dict[str, Dict[str, Any]]:
959*71ed611cSPaolo Bonzini    if not HAVE_TOMLLIB:
960*71ed611cSPaolo Bonzini        if sys.version_info < (3, 11):
961*71ed611cSPaolo Bonzini            raise Ouch("found no usable tomli, please install it")
962*71ed611cSPaolo Bonzini
963*71ed611cSPaolo Bonzini        raise Ouch(
964*71ed611cSPaolo Bonzini            "Python >=3.11 does not have tomllib... what have you done!?"
965*71ed611cSPaolo Bonzini        )
966*71ed611cSPaolo Bonzini
967*71ed611cSPaolo Bonzini    try:
968*71ed611cSPaolo Bonzini        # Use loads() to support both tomli v1.2.x (Ubuntu 22.04,
969*71ed611cSPaolo Bonzini        # Debian bullseye-backports) and v2.0.x
970*71ed611cSPaolo Bonzini        with open(file, "r", encoding="ascii") as depfile:
971*71ed611cSPaolo Bonzini            contents = depfile.read()
972*71ed611cSPaolo Bonzini            return tomllib.loads(contents)  # type: ignore
973*71ed611cSPaolo Bonzini    except tomllib.TOMLDecodeError as exc:
974*71ed611cSPaolo Bonzini        raise Ouch(f"parsing {file} failed: {exc}") from exc
975*71ed611cSPaolo Bonzini
976*71ed611cSPaolo Bonzini
977*71ed611cSPaolo Bonzinidef ensure_group(
978*71ed611cSPaolo Bonzini    file: str,
979*71ed611cSPaolo Bonzini    groups: Sequence[str],
980*71ed611cSPaolo Bonzini    online: bool = False,
981*71ed611cSPaolo Bonzini    wheels_dir: Optional[Union[str, Path]] = None,
982*71ed611cSPaolo Bonzini) -> None:
983*71ed611cSPaolo Bonzini    """
984*71ed611cSPaolo Bonzini    Use pip to ensure we have the package specified by @dep_specs.
985*71ed611cSPaolo Bonzini
986*71ed611cSPaolo Bonzini    If the package is already installed, do nothing. If online and
987*71ed611cSPaolo Bonzini    wheels_dir are both provided, prefer packages found in wheels_dir
988*71ed611cSPaolo Bonzini    first before connecting to PyPI.
989*71ed611cSPaolo Bonzini
990*71ed611cSPaolo Bonzini    :param dep_specs:
991*71ed611cSPaolo Bonzini        PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
992*71ed611cSPaolo Bonzini    :param online: If True, fall back to PyPI.
993*71ed611cSPaolo Bonzini    :param wheels_dir: If specified, search this path for packages.
994*71ed611cSPaolo Bonzini    """
995*71ed611cSPaolo Bonzini
996*71ed611cSPaolo Bonzini    if not HAVE_DISTLIB:
997*71ed611cSPaolo Bonzini        raise Ouch("found no usable distlib, please install it")
998*71ed611cSPaolo Bonzini
999*71ed611cSPaolo Bonzini    parsed_deps = _parse_groups(file)
1000*71ed611cSPaolo Bonzini
1001*71ed611cSPaolo Bonzini    to_install: Dict[str, Dict[str, str]] = {}
1002*71ed611cSPaolo Bonzini    for group in groups:
1003*71ed611cSPaolo Bonzini        try:
1004*71ed611cSPaolo Bonzini            to_install.update(parsed_deps[group])
1005*71ed611cSPaolo Bonzini        except KeyError as exc:
1006*71ed611cSPaolo Bonzini            raise Ouch(f"group {group} not defined") from exc
1007*71ed611cSPaolo Bonzini
1008*71ed611cSPaolo Bonzini    result = _do_ensure(to_install, online, wheels_dir)
1009*71ed611cSPaolo Bonzini    if result:
1010*71ed611cSPaolo Bonzini        # Well, that's not good.
1011*71ed611cSPaolo Bonzini        if result[1]:
1012*71ed611cSPaolo Bonzini            raise Ouch(result[0])
1013*71ed611cSPaolo Bonzini        raise SystemExit(f"\n{result[0]}\n\n")
1014*71ed611cSPaolo Bonzini
1015*71ed611cSPaolo Bonzini
1016f1ad527fSJohn Snowdef post_venv_setup() -> None:
1017f1ad527fSJohn Snow    """
1018f1ad527fSJohn Snow    This is intended to be run *inside the venv* after it is created.
1019f1ad527fSJohn Snow    """
1020f1ad527fSJohn Snow    logger.debug("post_venv_setup()")
1021c8049626SJohn Snow    # Test for a broken pip (Debian 10 or derivative?) and fix it if needed
1022c8049626SJohn Snow    if not checkpip():
1023c8049626SJohn Snow        # Finally, generate a 'pip' script so the venv is usable in a normal
1024f1ad527fSJohn Snow        # way from the CLI. This only happens when we inherited pip from a
1025f1ad527fSJohn Snow        # parent/system-site and haven't run ensurepip in some way.
1026f1ad527fSJohn Snow        generate_console_scripts(["pip"])
1027f1ad527fSJohn Snow
1028f1ad527fSJohn Snow
1029dd84028fSJohn Snowdef _add_create_subcommand(subparsers: Any) -> None:
1030dd84028fSJohn Snow    subparser = subparsers.add_parser("create", help="create a venv")
1031dd84028fSJohn Snow    subparser.add_argument(
1032dd84028fSJohn Snow        "target",
1033dd84028fSJohn Snow        type=str,
1034dd84028fSJohn Snow        action="store",
1035dd84028fSJohn Snow        help="Target directory to install virtual environment into.",
1036dd84028fSJohn Snow    )
1037dd84028fSJohn Snow
1038dd84028fSJohn Snow
1039f1ad527fSJohn Snowdef _add_post_init_subcommand(subparsers: Any) -> None:
1040f1ad527fSJohn Snow    subparsers.add_parser("post_init", help="post-venv initialization")
1041f1ad527fSJohn Snow
1042f1ad527fSJohn Snow
1043*71ed611cSPaolo Bonzinidef _add_ensuregroup_subcommand(subparsers: Any) -> None:
1044*71ed611cSPaolo Bonzini    subparser = subparsers.add_parser(
1045*71ed611cSPaolo Bonzini        "ensuregroup",
1046*71ed611cSPaolo Bonzini        help="Ensure that the specified package group is installed.",
1047*71ed611cSPaolo Bonzini    )
1048*71ed611cSPaolo Bonzini    subparser.add_argument(
1049*71ed611cSPaolo Bonzini        "--online",
1050*71ed611cSPaolo Bonzini        action="store_true",
1051*71ed611cSPaolo Bonzini        help="Install packages from PyPI, if necessary.",
1052*71ed611cSPaolo Bonzini    )
1053*71ed611cSPaolo Bonzini    subparser.add_argument(
1054*71ed611cSPaolo Bonzini        "--dir",
1055*71ed611cSPaolo Bonzini        type=str,
1056*71ed611cSPaolo Bonzini        action="store",
1057*71ed611cSPaolo Bonzini        help="Path to vendored packages where we may install from.",
1058*71ed611cSPaolo Bonzini    )
1059*71ed611cSPaolo Bonzini    subparser.add_argument(
1060*71ed611cSPaolo Bonzini        "file",
1061*71ed611cSPaolo Bonzini        type=str,
1062*71ed611cSPaolo Bonzini        action="store",
1063*71ed611cSPaolo Bonzini        help=("Path to a TOML file describing package groups"),
1064*71ed611cSPaolo Bonzini    )
1065*71ed611cSPaolo Bonzini    subparser.add_argument(
1066*71ed611cSPaolo Bonzini        "group",
1067*71ed611cSPaolo Bonzini        type=str,
1068*71ed611cSPaolo Bonzini        action="store",
1069*71ed611cSPaolo Bonzini        help="One or more package group names",
1070*71ed611cSPaolo Bonzini        nargs="+",
1071*71ed611cSPaolo Bonzini    )
1072*71ed611cSPaolo Bonzini
1073*71ed611cSPaolo Bonzini
1074c5538eedSJohn Snowdef _add_ensure_subcommand(subparsers: Any) -> None:
1075c5538eedSJohn Snow    subparser = subparsers.add_parser(
1076c5538eedSJohn Snow        "ensure", help="Ensure that the specified package is installed."
1077c5538eedSJohn Snow    )
1078c5538eedSJohn Snow    subparser.add_argument(
1079c5538eedSJohn Snow        "--online",
1080c5538eedSJohn Snow        action="store_true",
1081c5538eedSJohn Snow        help="Install packages from PyPI, if necessary.",
1082c5538eedSJohn Snow    )
1083c5538eedSJohn Snow    subparser.add_argument(
1084c5538eedSJohn Snow        "--dir",
1085c5538eedSJohn Snow        type=str,
1086c5538eedSJohn Snow        action="store",
1087c5538eedSJohn Snow        help="Path to vendored packages where we may install from.",
1088c5538eedSJohn Snow    )
1089c5538eedSJohn Snow    subparser.add_argument(
10904695a22eSJohn Snow        "--diagnose",
10914695a22eSJohn Snow        type=str,
10924695a22eSJohn Snow        action="store",
10934695a22eSJohn Snow        help=(
10944695a22eSJohn Snow            "Name of a shell utility to use for "
10954695a22eSJohn Snow            "diagnostics if this command fails."
10964695a22eSJohn Snow        ),
10974695a22eSJohn Snow    )
10984695a22eSJohn Snow    subparser.add_argument(
1099c5538eedSJohn Snow        "dep_specs",
1100c5538eedSJohn Snow        type=str,
1101c5538eedSJohn Snow        action="store",
1102c5538eedSJohn Snow        help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'",
1103c5538eedSJohn Snow        nargs="+",
1104c5538eedSJohn Snow    )
1105c5538eedSJohn Snow
1106c5538eedSJohn Snow
1107dd84028fSJohn Snowdef main() -> int:
1108dd84028fSJohn Snow    """CLI interface to make_qemu_venv. See module docstring."""
1109dd84028fSJohn Snow    if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"):
1110dd84028fSJohn Snow        # You're welcome.
1111dd84028fSJohn Snow        logging.basicConfig(level=logging.DEBUG)
1112c5538eedSJohn Snow    else:
1113c5538eedSJohn Snow        if os.environ.get("V"):
1114dd84028fSJohn Snow            logging.basicConfig(level=logging.INFO)
1115dd84028fSJohn Snow
1116dd84028fSJohn Snow    parser = argparse.ArgumentParser(
1117dd84028fSJohn Snow        prog="mkvenv",
1118dd84028fSJohn Snow        description="QEMU pyvenv bootstrapping utility",
1119dd84028fSJohn Snow    )
1120dd84028fSJohn Snow    subparsers = parser.add_subparsers(
1121dd84028fSJohn Snow        title="Commands",
1122dd84028fSJohn Snow        dest="command",
112302312f1aSPaolo Bonzini        required=True,
1124dd84028fSJohn Snow        metavar="command",
1125dd84028fSJohn Snow        help="Description",
1126dd84028fSJohn Snow    )
1127dd84028fSJohn Snow
1128dd84028fSJohn Snow    _add_create_subcommand(subparsers)
1129f1ad527fSJohn Snow    _add_post_init_subcommand(subparsers)
1130c5538eedSJohn Snow    _add_ensure_subcommand(subparsers)
1131*71ed611cSPaolo Bonzini    _add_ensuregroup_subcommand(subparsers)
1132dd84028fSJohn Snow
1133dd84028fSJohn Snow    args = parser.parse_args()
1134dd84028fSJohn Snow    try:
1135dd84028fSJohn Snow        if args.command == "create":
1136dd84028fSJohn Snow            make_venv(
1137dd84028fSJohn Snow                args.target,
1138dd84028fSJohn Snow                system_site_packages=True,
1139dd84028fSJohn Snow                clear=True,
1140dd84028fSJohn Snow            )
1141f1ad527fSJohn Snow        if args.command == "post_init":
1142f1ad527fSJohn Snow            post_venv_setup()
1143c5538eedSJohn Snow        if args.command == "ensure":
1144c5538eedSJohn Snow            ensure(
1145c5538eedSJohn Snow                dep_specs=args.dep_specs,
1146c5538eedSJohn Snow                online=args.online,
1147c5538eedSJohn Snow                wheels_dir=args.dir,
11484695a22eSJohn Snow                prog=args.diagnose,
1149c5538eedSJohn Snow            )
1150*71ed611cSPaolo Bonzini        if args.command == "ensuregroup":
1151*71ed611cSPaolo Bonzini            ensure_group(
1152*71ed611cSPaolo Bonzini                file=args.file,
1153*71ed611cSPaolo Bonzini                groups=args.group,
1154*71ed611cSPaolo Bonzini                online=args.online,
1155*71ed611cSPaolo Bonzini                wheels_dir=args.dir,
1156*71ed611cSPaolo Bonzini            )
1157dd84028fSJohn Snow        logger.debug("mkvenv.py %s: exiting", args.command)
1158dd84028fSJohn Snow    except Ouch as exc:
1159dd84028fSJohn Snow        print("\n*** Ouch! ***\n", file=sys.stderr)
1160dd84028fSJohn Snow        print(str(exc), "\n\n", file=sys.stderr)
1161dd84028fSJohn Snow        return 1
1162dd84028fSJohn Snow    except SystemExit:
1163dd84028fSJohn Snow        raise
1164dd84028fSJohn Snow    except:  # pylint: disable=bare-except
1165dd84028fSJohn Snow        logger.exception("mkvenv did not complete successfully:")
1166dd84028fSJohn Snow        return 2
1167dd84028fSJohn Snow    return 0
1168dd84028fSJohn Snow
1169dd84028fSJohn Snow
1170dd84028fSJohn Snowif __name__ == "__main__":
1171dd84028fSJohn Snow    sys.exit(main())
1172