xref: /qemu/python/scripts/mkvenv.py (revision 47a90a51a9c24ba10c58c0cd09d2117cf9e3fde2)
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.
17dd84028fSJohn Snow
18dd84028fSJohn Snow--------------------------------------------------
19dd84028fSJohn Snow
20dd84028fSJohn Snowusage: mkvenv create [-h] target
21dd84028fSJohn Snow
22dd84028fSJohn Snowpositional arguments:
23dd84028fSJohn Snow  target      Target directory to install virtual environment into.
24dd84028fSJohn Snow
25dd84028fSJohn Snowoptions:
26dd84028fSJohn Snow  -h, --help  show this help message and exit
27dd84028fSJohn Snow
28c5538eedSJohn Snow--------------------------------------------------
29c5538eedSJohn Snow
30f1ad527fSJohn Snowusage: mkvenv post_init [-h]
31f1ad527fSJohn Snow
32f1ad527fSJohn Snowoptions:
33f1ad527fSJohn Snow  -h, --help         show this help message and exit
34f1ad527fSJohn Snow
35f1ad527fSJohn Snow--------------------------------------------------
36f1ad527fSJohn Snow
37c5538eedSJohn Snowusage: mkvenv ensure [-h] [--online] [--dir DIR] dep_spec...
38c5538eedSJohn Snow
39c5538eedSJohn Snowpositional arguments:
40c5538eedSJohn Snow  dep_spec    PEP 508 Dependency specification, e.g. 'meson>=0.61.5'
41c5538eedSJohn Snow
42c5538eedSJohn Snowoptions:
43c5538eedSJohn Snow  -h, --help  show this help message and exit
44c5538eedSJohn Snow  --online    Install packages from PyPI, if necessary.
45c5538eedSJohn Snow  --dir DIR   Path to vendored packages where we may install from.
46c5538eedSJohn Snow
47dd84028fSJohn Snow"""
48dd84028fSJohn Snow
49dd84028fSJohn Snow# Copyright (C) 2022-2023 Red Hat, Inc.
50dd84028fSJohn Snow#
51dd84028fSJohn Snow# Authors:
52dd84028fSJohn Snow#  John Snow <jsnow@redhat.com>
53dd84028fSJohn Snow#  Paolo Bonzini <pbonzini@redhat.com>
54dd84028fSJohn Snow#
55dd84028fSJohn Snow# This work is licensed under the terms of the GNU GPL, version 2 or
56dd84028fSJohn Snow# later. See the COPYING file in the top-level directory.
57dd84028fSJohn Snow
58dd84028fSJohn Snowimport argparse
59a9dbde71SJohn Snowfrom importlib.util import find_spec
60dd84028fSJohn Snowimport logging
61dd84028fSJohn Snowimport os
62dd84028fSJohn Snowfrom pathlib import Path
634695a22eSJohn Snowimport re
644695a22eSJohn Snowimport shutil
65dee01b82SJohn Snowimport site
66dd84028fSJohn Snowimport subprocess
67dd84028fSJohn Snowimport sys
68dee01b82SJohn Snowimport sysconfig
69dd84028fSJohn Snowfrom types import SimpleNamespace
70c5538eedSJohn Snowfrom typing import (
71c5538eedSJohn Snow    Any,
7292834894SJohn Snow    Iterator,
73c5538eedSJohn Snow    Optional,
74c5538eedSJohn Snow    Sequence,
754695a22eSJohn Snow    Tuple,
76c5538eedSJohn Snow    Union,
77c5538eedSJohn Snow)
78dd84028fSJohn Snowimport venv
79c5538eedSJohn Snow
8068ea6d17SJohn Snow
8168ea6d17SJohn Snow# Try to load distlib, with a fallback to pip's vendored version.
8268ea6d17SJohn Snow# HAVE_DISTLIB is checked below, just-in-time, so that mkvenv does not fail
8368ea6d17SJohn Snow# outside the venv or before a potential call to ensurepip in checkpip().
8468ea6d17SJohn SnowHAVE_DISTLIB = True
8568ea6d17SJohn Snowtry:
8692834894SJohn Snow    import distlib.scripts
87c5538eedSJohn Snow    import distlib.version
8868ea6d17SJohn Snowexcept ImportError:
8968ea6d17SJohn Snow    try:
9068ea6d17SJohn Snow        # Reach into pip's cookie jar.  pylint and flake8 don't understand
9168ea6d17SJohn Snow        # that these imports will be used via distlib.xxx.
9268ea6d17SJohn Snow        from pip._vendor import distlib
9368ea6d17SJohn Snow        import pip._vendor.distlib.scripts  # noqa, pylint: disable=unused-import
9468ea6d17SJohn Snow        import pip._vendor.distlib.version  # noqa, pylint: disable=unused-import
9568ea6d17SJohn Snow    except ImportError:
9668ea6d17SJohn Snow        HAVE_DISTLIB = False
97dd84028fSJohn Snow
98dd84028fSJohn Snow# Do not add any mandatory dependencies from outside the stdlib:
99dd84028fSJohn Snow# This script *must* be usable standalone!
100dd84028fSJohn Snow
101dd84028fSJohn SnowDirType = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"]
102dd84028fSJohn Snowlogger = logging.getLogger("mkvenv")
103dd84028fSJohn Snow
104dd84028fSJohn Snow
105dee01b82SJohn Snowdef inside_a_venv() -> bool:
106dee01b82SJohn Snow    """Returns True if it is executed inside of a virtual environment."""
107dee01b82SJohn Snow    return sys.prefix != sys.base_prefix
108dee01b82SJohn Snow
109dee01b82SJohn Snow
110dd84028fSJohn Snowclass Ouch(RuntimeError):
111dd84028fSJohn Snow    """An Exception class we can't confuse with a builtin."""
112dd84028fSJohn Snow
113dd84028fSJohn Snow
114dd84028fSJohn Snowclass QemuEnvBuilder(venv.EnvBuilder):
115dd84028fSJohn Snow    """
116dd84028fSJohn Snow    An extension of venv.EnvBuilder for building QEMU's configure-time venv.
117dd84028fSJohn Snow
118dee01b82SJohn Snow    The primary difference is that it emulates a "nested" virtual
119dee01b82SJohn Snow    environment when invoked from inside of an existing virtual
120f1ad527fSJohn Snow    environment by including packages from the parent.  Also,
121f1ad527fSJohn Snow    "ensurepip" is replaced if possible with just recreating pip's
122f1ad527fSJohn Snow    console_scripts inside the virtual environment.
123dd84028fSJohn Snow
124dd84028fSJohn Snow    Parameters for base class init:
125dd84028fSJohn Snow      - system_site_packages: bool = False
126dd84028fSJohn Snow      - clear: bool = False
127dd84028fSJohn Snow      - symlinks: bool = False
128dd84028fSJohn Snow      - upgrade: bool = False
129dd84028fSJohn Snow      - with_pip: bool = False
130dd84028fSJohn Snow      - prompt: Optional[str] = None
131dd84028fSJohn Snow      - upgrade_deps: bool = False             (Since 3.9)
132dd84028fSJohn Snow    """
133dd84028fSJohn Snow
134dd84028fSJohn Snow    def __init__(self, *args: Any, **kwargs: Any) -> None:
135dd84028fSJohn Snow        logger.debug("QemuEnvBuilder.__init__(...)")
136a9dbde71SJohn Snow
137dee01b82SJohn Snow        # For nested venv emulation:
138dee01b82SJohn Snow        self.use_parent_packages = False
139dee01b82SJohn Snow        if inside_a_venv():
140dee01b82SJohn Snow            # Include parent packages only if we're in a venv and
141dee01b82SJohn Snow            # system_site_packages was True.
142dee01b82SJohn Snow            self.use_parent_packages = kwargs.pop(
143dee01b82SJohn Snow                "system_site_packages", False
144dee01b82SJohn Snow            )
145dee01b82SJohn Snow            # Include system_site_packages only when the parent,
146dee01b82SJohn Snow            # The venv we are currently in, also does so.
147dee01b82SJohn Snow            kwargs["system_site_packages"] = sys.base_prefix in site.PREFIXES
148dee01b82SJohn Snow
149f1ad527fSJohn Snow        # ensurepip is slow: venv creation can be very fast for cases where
150f1ad527fSJohn Snow        # we allow the use of system_site_packages. Therefore, ensurepip is
151f1ad527fSJohn Snow        # replaced with our own script generation once the virtual environment
152f1ad527fSJohn Snow        # is setup.
153f1ad527fSJohn Snow        self.want_pip = kwargs.get("with_pip", False)
154f1ad527fSJohn Snow        if self.want_pip:
155f1ad527fSJohn Snow            if (
156f1ad527fSJohn Snow                kwargs.get("system_site_packages", False)
157f1ad527fSJohn Snow                and not need_ensurepip()
158f1ad527fSJohn Snow            ):
159f1ad527fSJohn Snow                kwargs["with_pip"] = False
160f1ad527fSJohn Snow            else:
161c8049626SJohn Snow                check_ensurepip(suggest_remedy=True)
162a9dbde71SJohn Snow
163dd84028fSJohn Snow        super().__init__(*args, **kwargs)
164dd84028fSJohn Snow
165dd84028fSJohn Snow        # Make the context available post-creation:
166dd84028fSJohn Snow        self._context: Optional[SimpleNamespace] = None
167dd84028fSJohn Snow
168dee01b82SJohn Snow    def get_parent_libpath(self) -> Optional[str]:
169dee01b82SJohn Snow        """Return the libpath of the parent venv, if applicable."""
170dee01b82SJohn Snow        if self.use_parent_packages:
171dee01b82SJohn Snow            return sysconfig.get_path("purelib")
172dee01b82SJohn Snow        return None
173dee01b82SJohn Snow
174dee01b82SJohn Snow    @staticmethod
175dee01b82SJohn Snow    def compute_venv_libpath(context: SimpleNamespace) -> str:
176dee01b82SJohn Snow        """
177dee01b82SJohn Snow        Compatibility wrapper for context.lib_path for Python < 3.12
178dee01b82SJohn Snow        """
179dee01b82SJohn Snow        # Python 3.12+, not strictly necessary because it's documented
180dee01b82SJohn Snow        # to be the same as 3.10 code below:
181dee01b82SJohn Snow        if sys.version_info >= (3, 12):
182dee01b82SJohn Snow            return context.lib_path
183dee01b82SJohn Snow
184dee01b82SJohn Snow        # Python 3.10+
185dee01b82SJohn Snow        if "venv" in sysconfig.get_scheme_names():
186dee01b82SJohn Snow            lib_path = sysconfig.get_path(
187dee01b82SJohn Snow                "purelib", scheme="venv", vars={"base": context.env_dir}
188dee01b82SJohn Snow            )
189dee01b82SJohn Snow            assert lib_path is not None
190dee01b82SJohn Snow            return lib_path
191dee01b82SJohn Snow
192dee01b82SJohn Snow        # For Python <= 3.9 we need to hardcode this. Fortunately the
193dee01b82SJohn Snow        # code below was the same in Python 3.6-3.10, so there is only
194dee01b82SJohn Snow        # one case.
195dee01b82SJohn Snow        if sys.platform == "win32":
196dee01b82SJohn Snow            return os.path.join(context.env_dir, "Lib", "site-packages")
197dee01b82SJohn Snow        return os.path.join(
198dee01b82SJohn Snow            context.env_dir,
199dee01b82SJohn Snow            "lib",
200dee01b82SJohn Snow            "python%d.%d" % sys.version_info[:2],
201dee01b82SJohn Snow            "site-packages",
202dee01b82SJohn Snow        )
203dee01b82SJohn Snow
204dd84028fSJohn Snow    def ensure_directories(self, env_dir: DirType) -> SimpleNamespace:
205dd84028fSJohn Snow        logger.debug("ensure_directories(env_dir=%s)", env_dir)
206dd84028fSJohn Snow        self._context = super().ensure_directories(env_dir)
207dd84028fSJohn Snow        return self._context
208dd84028fSJohn Snow
209dee01b82SJohn Snow    def create(self, env_dir: DirType) -> None:
210dee01b82SJohn Snow        logger.debug("create(env_dir=%s)", env_dir)
211dee01b82SJohn Snow        super().create(env_dir)
212dee01b82SJohn Snow        assert self._context is not None
213dee01b82SJohn Snow        self.post_post_setup(self._context)
214dee01b82SJohn Snow
215dee01b82SJohn Snow    def post_post_setup(self, context: SimpleNamespace) -> None:
216dee01b82SJohn Snow        """
217dee01b82SJohn Snow        The final, final hook. Enter the venv and run commands inside of it.
218dee01b82SJohn Snow        """
219dee01b82SJohn Snow        if self.use_parent_packages:
220dee01b82SJohn Snow            # We're inside of a venv and we want to include the parent
221dee01b82SJohn Snow            # venv's packages.
222dee01b82SJohn Snow            parent_libpath = self.get_parent_libpath()
223dee01b82SJohn Snow            assert parent_libpath is not None
224dee01b82SJohn Snow            logger.debug("parent_libpath: %s", parent_libpath)
225dee01b82SJohn Snow
226dee01b82SJohn Snow            our_libpath = self.compute_venv_libpath(context)
227dee01b82SJohn Snow            logger.debug("our_libpath: %s", our_libpath)
228dee01b82SJohn Snow
229dee01b82SJohn Snow            pth_file = os.path.join(our_libpath, "nested.pth")
230dee01b82SJohn Snow            with open(pth_file, "w", encoding="UTF-8") as file:
231dee01b82SJohn Snow                file.write(parent_libpath + os.linesep)
232dee01b82SJohn Snow
233f1ad527fSJohn Snow        if self.want_pip:
234f1ad527fSJohn Snow            args = [
235f1ad527fSJohn Snow                context.env_exe,
236f1ad527fSJohn Snow                __file__,
237f1ad527fSJohn Snow                "post_init",
238f1ad527fSJohn Snow            ]
239f1ad527fSJohn Snow            subprocess.run(args, check=True)
240f1ad527fSJohn Snow
241dd84028fSJohn Snow    def get_value(self, field: str) -> str:
242dd84028fSJohn Snow        """
243dd84028fSJohn Snow        Get a string value from the context namespace after a call to build.
244dd84028fSJohn Snow
245dd84028fSJohn Snow        For valid field names, see:
246dd84028fSJohn Snow        https://docs.python.org/3/library/venv.html#venv.EnvBuilder.ensure_directories
247dd84028fSJohn Snow        """
248dd84028fSJohn Snow        ret = getattr(self._context, field)
249dd84028fSJohn Snow        assert isinstance(ret, str)
250dd84028fSJohn Snow        return ret
251dd84028fSJohn Snow
252dd84028fSJohn Snow
253f1ad527fSJohn Snowdef need_ensurepip() -> bool:
254f1ad527fSJohn Snow    """
255f1ad527fSJohn Snow    Tests for the presence of setuptools and pip.
256f1ad527fSJohn Snow
257f1ad527fSJohn Snow    :return: `True` if we do not detect both packages.
258f1ad527fSJohn Snow    """
259f1ad527fSJohn Snow    # Don't try to actually import them, it's fraught with danger:
260f1ad527fSJohn Snow    # https://github.com/pypa/setuptools/issues/2993
261f1ad527fSJohn Snow    if find_spec("setuptools") and find_spec("pip"):
262f1ad527fSJohn Snow        return False
263f1ad527fSJohn Snow    return True
264f1ad527fSJohn Snow
265f1ad527fSJohn Snow
266c8049626SJohn Snowdef check_ensurepip(prefix: str = "", suggest_remedy: bool = False) -> None:
267a9dbde71SJohn Snow    """
268a9dbde71SJohn Snow    Check that we have ensurepip.
269a9dbde71SJohn Snow
270a9dbde71SJohn Snow    Raise a fatal exception with a helpful hint if it isn't available.
271a9dbde71SJohn Snow    """
272a9dbde71SJohn Snow    if not find_spec("ensurepip"):
273a9dbde71SJohn Snow        msg = (
274a9dbde71SJohn Snow            "Python's ensurepip module is not found.\n"
275a9dbde71SJohn Snow            "It's normally part of the Python standard library, "
276a9dbde71SJohn Snow            "maybe your distribution packages it separately?\n"
277c8049626SJohn Snow            "(Debian puts ensurepip in its python3-venv package.)\n"
278c8049626SJohn Snow        )
279c8049626SJohn Snow        if suggest_remedy:
280c8049626SJohn Snow            msg += (
281a9dbde71SJohn Snow                "Either install ensurepip, or alleviate the need for it in the"
282a9dbde71SJohn Snow                " first place by installing pip and setuptools for "
283a9dbde71SJohn Snow                f"'{sys.executable}'.\n"
284a9dbde71SJohn Snow            )
285c8049626SJohn Snow        raise Ouch(prefix + msg)
286a9dbde71SJohn Snow
287a9dbde71SJohn Snow    # ensurepip uses pyexpat, which can also go missing on us:
288a9dbde71SJohn Snow    if not find_spec("pyexpat"):
289a9dbde71SJohn Snow        msg = (
290a9dbde71SJohn Snow            "Python's pyexpat module is not found.\n"
291a9dbde71SJohn Snow            "It's normally part of the Python standard library, "
292a9dbde71SJohn Snow            "maybe your distribution packages it separately?\n"
293c8049626SJohn Snow            "(NetBSD's pkgsrc debundles this to e.g. 'py310-expat'.)\n"
294c8049626SJohn Snow        )
295c8049626SJohn Snow        if suggest_remedy:
296c8049626SJohn Snow            msg += (
297a9dbde71SJohn Snow                "Either install pyexpat, or alleviate the need for it in the "
298a9dbde71SJohn Snow                "first place by installing pip and setuptools for "
299c8049626SJohn Snow                f"'{sys.executable}'.\n"
300a9dbde71SJohn Snow            )
301c8049626SJohn Snow        raise Ouch(prefix + msg)
302a9dbde71SJohn Snow
303a9dbde71SJohn Snow
304dd84028fSJohn Snowdef make_venv(  # pylint: disable=too-many-arguments
305dd84028fSJohn Snow    env_dir: Union[str, Path],
306dd84028fSJohn Snow    system_site_packages: bool = False,
307dd84028fSJohn Snow    clear: bool = True,
308dd84028fSJohn Snow    symlinks: Optional[bool] = None,
309dd84028fSJohn Snow    with_pip: bool = True,
310dd84028fSJohn Snow) -> None:
311dd84028fSJohn Snow    """
312dd84028fSJohn Snow    Create a venv using `QemuEnvBuilder`.
313dd84028fSJohn Snow
314dd84028fSJohn Snow    This is analogous to the `venv.create` module-level convenience
315dd84028fSJohn Snow    function that is part of the Python stdblib, except it uses
316dd84028fSJohn Snow    `QemuEnvBuilder` instead.
317dd84028fSJohn Snow
318dd84028fSJohn Snow    :param env_dir: The directory to create/install to.
319dd84028fSJohn Snow    :param system_site_packages:
320dd84028fSJohn Snow        Allow inheriting packages from the system installation.
321dd84028fSJohn Snow    :param clear: When True, fully remove any prior venv and files.
322dd84028fSJohn Snow    :param symlinks:
323dd84028fSJohn Snow        Whether to use symlinks to the target interpreter or not. If
324dd84028fSJohn Snow        left unspecified, it will use symlinks except on Windows to
325dd84028fSJohn Snow        match behavior with the "venv" CLI tool.
326dd84028fSJohn Snow    :param with_pip:
327dd84028fSJohn Snow        Whether to install "pip" binaries or not.
328dd84028fSJohn Snow    """
329dd84028fSJohn Snow    logger.debug(
330dd84028fSJohn Snow        "%s: make_venv(env_dir=%s, system_site_packages=%s, "
331dd84028fSJohn Snow        "clear=%s, symlinks=%s, with_pip=%s)",
332dd84028fSJohn Snow        __file__,
333dd84028fSJohn Snow        str(env_dir),
334dd84028fSJohn Snow        system_site_packages,
335dd84028fSJohn Snow        clear,
336dd84028fSJohn Snow        symlinks,
337dd84028fSJohn Snow        with_pip,
338dd84028fSJohn Snow    )
339dd84028fSJohn Snow
340dd84028fSJohn Snow    if symlinks is None:
341dd84028fSJohn Snow        # Default behavior of standard venv CLI
342dd84028fSJohn Snow        symlinks = os.name != "nt"
343dd84028fSJohn Snow
344dd84028fSJohn Snow    builder = QemuEnvBuilder(
345dd84028fSJohn Snow        system_site_packages=system_site_packages,
346dd84028fSJohn Snow        clear=clear,
347dd84028fSJohn Snow        symlinks=symlinks,
348dd84028fSJohn Snow        with_pip=with_pip,
349dd84028fSJohn Snow    )
350dd84028fSJohn Snow
351dd84028fSJohn Snow    style = "non-isolated" if builder.system_site_packages else "isolated"
352dee01b82SJohn Snow    nested = ""
353dee01b82SJohn Snow    if builder.use_parent_packages:
354dee01b82SJohn Snow        nested = f"(with packages from '{builder.get_parent_libpath()}') "
355dd84028fSJohn Snow    print(
356dd84028fSJohn Snow        f"mkvenv: Creating {style} virtual environment"
357dee01b82SJohn Snow        f" {nested}at '{str(env_dir)}'",
358dd84028fSJohn Snow        file=sys.stderr,
359dd84028fSJohn Snow    )
360dd84028fSJohn Snow
361dd84028fSJohn Snow    try:
362dd84028fSJohn Snow        logger.debug("Invoking builder.create()")
363dd84028fSJohn Snow        try:
364dd84028fSJohn Snow            builder.create(str(env_dir))
365dd84028fSJohn Snow        except SystemExit as exc:
366dd84028fSJohn Snow            # Some versions of the venv module raise SystemExit; *nasty*!
367dd84028fSJohn Snow            # We want the exception that prompted it. It might be a subprocess
368dd84028fSJohn Snow            # error that has output we *really* want to see.
369dd84028fSJohn Snow            logger.debug("Intercepted SystemExit from EnvBuilder.create()")
370dd84028fSJohn Snow            raise exc.__cause__ or exc.__context__ or exc
371dd84028fSJohn Snow        logger.debug("builder.create() finished")
372dd84028fSJohn Snow    except subprocess.CalledProcessError as exc:
373dd84028fSJohn Snow        logger.error("mkvenv subprocess failed:")
374dd84028fSJohn Snow        logger.error("cmd: %s", exc.cmd)
375dd84028fSJohn Snow        logger.error("returncode: %d", exc.returncode)
376dd84028fSJohn Snow
377dd84028fSJohn Snow        def _stringify(data: Union[str, bytes]) -> str:
378dd84028fSJohn Snow            if isinstance(data, bytes):
379dd84028fSJohn Snow                return data.decode()
380dd84028fSJohn Snow            return data
381dd84028fSJohn Snow
382dd84028fSJohn Snow        lines = []
383dd84028fSJohn Snow        if exc.stdout:
384dd84028fSJohn Snow            lines.append("========== stdout ==========")
385dd84028fSJohn Snow            lines.append(_stringify(exc.stdout))
386dd84028fSJohn Snow            lines.append("============================")
387dd84028fSJohn Snow        if exc.stderr:
388dd84028fSJohn Snow            lines.append("========== stderr ==========")
389dd84028fSJohn Snow            lines.append(_stringify(exc.stderr))
390dd84028fSJohn Snow            lines.append("============================")
391dd84028fSJohn Snow        if lines:
392dd84028fSJohn Snow            logger.error(os.linesep.join(lines))
393dd84028fSJohn Snow
394dd84028fSJohn Snow        raise Ouch("VENV creation subprocess failed.") from exc
395dd84028fSJohn Snow
396dd84028fSJohn Snow    # print the python executable to stdout for configure.
397dd84028fSJohn Snow    print(builder.get_value("env_exe"))
398dd84028fSJohn Snow
399dd84028fSJohn Snow
40092834894SJohn Snowdef _gen_importlib(packages: Sequence[str]) -> Iterator[str]:
40192834894SJohn Snow    # pylint: disable=import-outside-toplevel
40292834894SJohn Snow    # pylint: disable=no-name-in-module
40392834894SJohn Snow    # pylint: disable=import-error
40492834894SJohn Snow    try:
40592834894SJohn Snow        # First preference: Python 3.8+ stdlib
40692834894SJohn Snow        from importlib.metadata import (  # type: ignore
40792834894SJohn Snow            PackageNotFoundError,
40892834894SJohn Snow            distribution,
40992834894SJohn Snow        )
41092834894SJohn Snow    except ImportError as exc:
41192834894SJohn Snow        logger.debug("%s", str(exc))
41292834894SJohn Snow        # Second preference: Commonly available PyPI backport
41392834894SJohn Snow        from importlib_metadata import (  # type: ignore
41492834894SJohn Snow            PackageNotFoundError,
41592834894SJohn Snow            distribution,
41692834894SJohn Snow        )
41792834894SJohn Snow
41892834894SJohn Snow    def _generator() -> Iterator[str]:
41992834894SJohn Snow        for package in packages:
42092834894SJohn Snow            try:
42192834894SJohn Snow                entry_points = distribution(package).entry_points
42292834894SJohn Snow            except PackageNotFoundError:
42392834894SJohn Snow                continue
42492834894SJohn Snow
42592834894SJohn Snow            # The EntryPoints type is only available in 3.10+,
42692834894SJohn Snow            # treat this as a vanilla list and filter it ourselves.
42792834894SJohn Snow            entry_points = filter(
42892834894SJohn Snow                lambda ep: ep.group == "console_scripts", entry_points
42992834894SJohn Snow            )
43092834894SJohn Snow
43192834894SJohn Snow            for entry_point in entry_points:
43292834894SJohn Snow                yield f"{entry_point.name} = {entry_point.value}"
43392834894SJohn Snow
43492834894SJohn Snow    return _generator()
43592834894SJohn Snow
43692834894SJohn Snow
43792834894SJohn Snowdef _gen_pkg_resources(packages: Sequence[str]) -> Iterator[str]:
43892834894SJohn Snow    # pylint: disable=import-outside-toplevel
43992834894SJohn Snow    # Bundled with setuptools; has a good chance of being available.
44092834894SJohn Snow    import pkg_resources
44192834894SJohn Snow
44292834894SJohn Snow    def _generator() -> Iterator[str]:
44392834894SJohn Snow        for package in packages:
44492834894SJohn Snow            try:
44592834894SJohn Snow                eps = pkg_resources.get_entry_map(package, "console_scripts")
44692834894SJohn Snow            except pkg_resources.DistributionNotFound:
44792834894SJohn Snow                continue
44892834894SJohn Snow
44992834894SJohn Snow            for entry_point in eps.values():
45092834894SJohn Snow                yield str(entry_point)
45192834894SJohn Snow
45292834894SJohn Snow    return _generator()
45392834894SJohn Snow
45492834894SJohn Snow
45592834894SJohn Snowdef generate_console_scripts(
45692834894SJohn Snow    packages: Sequence[str],
45792834894SJohn Snow    python_path: Optional[str] = None,
45892834894SJohn Snow    bin_path: Optional[str] = None,
45992834894SJohn Snow) -> None:
46092834894SJohn Snow    """
46192834894SJohn Snow    Generate script shims for console_script entry points in @packages.
46292834894SJohn Snow    """
46392834894SJohn Snow    if python_path is None:
46492834894SJohn Snow        python_path = sys.executable
46592834894SJohn Snow    if bin_path is None:
46692834894SJohn Snow        bin_path = sysconfig.get_path("scripts")
46792834894SJohn Snow        assert bin_path is not None
46892834894SJohn Snow
46992834894SJohn Snow    logger.debug(
47092834894SJohn Snow        "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)",
47192834894SJohn Snow        packages,
47292834894SJohn Snow        python_path,
47392834894SJohn Snow        bin_path,
47492834894SJohn Snow    )
47592834894SJohn Snow
47692834894SJohn Snow    if not packages:
47792834894SJohn Snow        return
47892834894SJohn Snow
47992834894SJohn Snow    def _get_entry_points() -> Iterator[str]:
48092834894SJohn Snow        """Python 3.7 compatibility shim for iterating entry points."""
48192834894SJohn Snow        # Python 3.8+, or Python 3.7 with importlib_metadata installed.
48292834894SJohn Snow        try:
48392834894SJohn Snow            return _gen_importlib(packages)
48492834894SJohn Snow        except ImportError as exc:
48592834894SJohn Snow            logger.debug("%s", str(exc))
48692834894SJohn Snow
48792834894SJohn Snow        # Python 3.7 with setuptools installed.
48892834894SJohn Snow        try:
48992834894SJohn Snow            return _gen_pkg_resources(packages)
49092834894SJohn Snow        except ImportError as exc:
49192834894SJohn Snow            logger.debug("%s", str(exc))
49292834894SJohn Snow            raise Ouch(
49392834894SJohn Snow                "Neither importlib.metadata nor pkg_resources found, "
49492834894SJohn Snow                "can't generate console script shims.\n"
49592834894SJohn Snow                "Use Python 3.8+, or install importlib-metadata or setuptools."
49692834894SJohn Snow            ) from exc
49792834894SJohn Snow
49892834894SJohn Snow    maker = distlib.scripts.ScriptMaker(None, bin_path)
49992834894SJohn Snow    maker.variants = {""}
50092834894SJohn Snow    maker.clobber = False
50192834894SJohn Snow
50292834894SJohn Snow    for entry_point in _get_entry_points():
50392834894SJohn Snow        for filename in maker.make(entry_point):
50492834894SJohn Snow            logger.debug("wrote console_script '%s'", filename)
50592834894SJohn Snow
50692834894SJohn Snow
507c8049626SJohn Snowdef checkpip() -> bool:
508c8049626SJohn Snow    """
509c8049626SJohn Snow    Debian10 has a pip that's broken when used inside of a virtual environment.
510c8049626SJohn Snow
511c8049626SJohn Snow    We try to detect and correct that case here.
512c8049626SJohn Snow    """
513c8049626SJohn Snow    try:
514c8049626SJohn Snow        # pylint: disable=import-outside-toplevel,unused-import,import-error
515c8049626SJohn Snow        # pylint: disable=redefined-outer-name
516c8049626SJohn Snow        import pip._internal  # type: ignore  # noqa: F401
517c8049626SJohn Snow
518c8049626SJohn Snow        logger.debug("pip appears to be working correctly.")
519c8049626SJohn Snow        return False
520c8049626SJohn Snow    except ModuleNotFoundError as exc:
521c8049626SJohn Snow        if exc.name == "pip._internal":
522c8049626SJohn Snow            # Uh, fair enough. They did say "internal".
523c8049626SJohn Snow            # Let's just assume it's fine.
524c8049626SJohn Snow            return False
525c8049626SJohn Snow        logger.warning("pip appears to be malfunctioning: %s", str(exc))
526c8049626SJohn Snow
527c8049626SJohn Snow    check_ensurepip("pip appears to be non-functional, and ")
528c8049626SJohn Snow
529c8049626SJohn Snow    logger.debug("Attempting to repair pip ...")
530c8049626SJohn Snow    subprocess.run(
531c8049626SJohn Snow        (sys.executable, "-m", "ensurepip"),
532c8049626SJohn Snow        stdout=subprocess.DEVNULL,
533c8049626SJohn Snow        check=True,
534c8049626SJohn Snow    )
535c8049626SJohn Snow    logger.debug("Pip is now (hopefully) repaired!")
536c8049626SJohn Snow    return True
537c8049626SJohn Snow
538c8049626SJohn Snow
5394695a22eSJohn Snowdef pkgname_from_depspec(dep_spec: str) -> str:
5404695a22eSJohn Snow    """
5414695a22eSJohn Snow    Parse package name out of a PEP-508 depspec.
5424695a22eSJohn Snow
5434695a22eSJohn Snow    See https://peps.python.org/pep-0508/#names
5444695a22eSJohn Snow    """
5454695a22eSJohn Snow    match = re.match(
5464695a22eSJohn Snow        r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE
5474695a22eSJohn Snow    )
5484695a22eSJohn Snow    if not match:
5494695a22eSJohn Snow        raise ValueError(
5504695a22eSJohn Snow            f"dep_spec '{dep_spec}'"
5514695a22eSJohn Snow            " does not appear to contain a valid package name"
5524695a22eSJohn Snow        )
5534695a22eSJohn Snow    return match.group(0)
5544695a22eSJohn Snow
5554695a22eSJohn Snow
556*47a90a51SPaolo Bonzinidef _get_path_importlib(package: str) -> Optional[str]:
557*47a90a51SPaolo Bonzini    # pylint: disable=import-outside-toplevel
558*47a90a51SPaolo Bonzini    # pylint: disable=no-name-in-module
559*47a90a51SPaolo Bonzini    # pylint: disable=import-error
560*47a90a51SPaolo Bonzini    try:
561*47a90a51SPaolo Bonzini        # First preference: Python 3.8+ stdlib
562*47a90a51SPaolo Bonzini        from importlib.metadata import (  # type: ignore
563*47a90a51SPaolo Bonzini            PackageNotFoundError,
564*47a90a51SPaolo Bonzini            distribution,
565*47a90a51SPaolo Bonzini        )
566*47a90a51SPaolo Bonzini    except ImportError as exc:
567*47a90a51SPaolo Bonzini        logger.debug("%s", str(exc))
568*47a90a51SPaolo Bonzini        # Second preference: Commonly available PyPI backport
569*47a90a51SPaolo Bonzini        from importlib_metadata import (  # type: ignore
570*47a90a51SPaolo Bonzini            PackageNotFoundError,
571*47a90a51SPaolo Bonzini            distribution,
572*47a90a51SPaolo Bonzini        )
573*47a90a51SPaolo Bonzini
574*47a90a51SPaolo Bonzini    try:
575*47a90a51SPaolo Bonzini        return str(distribution(package).locate_file("."))
576*47a90a51SPaolo Bonzini    except PackageNotFoundError:
577*47a90a51SPaolo Bonzini        return None
578*47a90a51SPaolo Bonzini
579*47a90a51SPaolo Bonzini
580*47a90a51SPaolo Bonzinidef _get_path_pkg_resources(package: str) -> Optional[str]:
581*47a90a51SPaolo Bonzini    # pylint: disable=import-outside-toplevel
582*47a90a51SPaolo Bonzini    # Bundled with setuptools; has a good chance of being available.
583*47a90a51SPaolo Bonzini    import pkg_resources
584*47a90a51SPaolo Bonzini
585*47a90a51SPaolo Bonzini    try:
586*47a90a51SPaolo Bonzini        return str(pkg_resources.get_distribution(package).location)
587*47a90a51SPaolo Bonzini    except pkg_resources.DistributionNotFound:
588*47a90a51SPaolo Bonzini        return None
589*47a90a51SPaolo Bonzini
590*47a90a51SPaolo Bonzini
591*47a90a51SPaolo Bonzinidef _get_path(package: str) -> Optional[str]:
592*47a90a51SPaolo Bonzini    try:
593*47a90a51SPaolo Bonzini        return _get_path_importlib(package)
594*47a90a51SPaolo Bonzini    except ImportError as exc:
595*47a90a51SPaolo Bonzini        logger.debug("%s", str(exc))
596*47a90a51SPaolo Bonzini
597*47a90a51SPaolo Bonzini    try:
598*47a90a51SPaolo Bonzini        return _get_path_pkg_resources(package)
599*47a90a51SPaolo Bonzini    except ImportError as exc:
600*47a90a51SPaolo Bonzini        logger.debug("%s", str(exc))
601*47a90a51SPaolo Bonzini        raise Ouch(
602*47a90a51SPaolo Bonzini            "Neither importlib.metadata nor pkg_resources found. "
603*47a90a51SPaolo Bonzini            "Use Python 3.8+, or install importlib-metadata or setuptools."
604*47a90a51SPaolo Bonzini        ) from exc
605*47a90a51SPaolo Bonzini
606*47a90a51SPaolo Bonzini
607*47a90a51SPaolo Bonzinidef _path_is_prefix(prefix: Optional[str], path: str) -> bool:
608*47a90a51SPaolo Bonzini    try:
609*47a90a51SPaolo Bonzini        return (
610*47a90a51SPaolo Bonzini            prefix is not None and os.path.commonpath([prefix, path]) == prefix
611*47a90a51SPaolo Bonzini        )
612*47a90a51SPaolo Bonzini    except ValueError:
613*47a90a51SPaolo Bonzini        return False
614*47a90a51SPaolo Bonzini
615*47a90a51SPaolo Bonzini
616*47a90a51SPaolo Bonzinidef _is_system_package(package: str) -> bool:
617*47a90a51SPaolo Bonzini    path = _get_path(package)
618*47a90a51SPaolo Bonzini    return path is not None and not (
619*47a90a51SPaolo Bonzini        _path_is_prefix(sysconfig.get_path("purelib"), path)
620*47a90a51SPaolo Bonzini        or _path_is_prefix(sysconfig.get_path("platlib"), path)
621*47a90a51SPaolo Bonzini    )
622*47a90a51SPaolo Bonzini
623*47a90a51SPaolo Bonzini
624c673f3d0SPaolo Bonzinidef _get_version_importlib(package: str) -> Optional[str]:
625c673f3d0SPaolo Bonzini    # pylint: disable=import-outside-toplevel
626c673f3d0SPaolo Bonzini    # pylint: disable=no-name-in-module
627c673f3d0SPaolo Bonzini    # pylint: disable=import-error
628c673f3d0SPaolo Bonzini    try:
629c673f3d0SPaolo Bonzini        # First preference: Python 3.8+ stdlib
630c673f3d0SPaolo Bonzini        from importlib.metadata import (  # type: ignore
631c673f3d0SPaolo Bonzini            PackageNotFoundError,
632c673f3d0SPaolo Bonzini            distribution,
633c673f3d0SPaolo Bonzini        )
634c673f3d0SPaolo Bonzini    except ImportError as exc:
635c673f3d0SPaolo Bonzini        logger.debug("%s", str(exc))
636c673f3d0SPaolo Bonzini        # Second preference: Commonly available PyPI backport
637c673f3d0SPaolo Bonzini        from importlib_metadata import (  # type: ignore
638c673f3d0SPaolo Bonzini            PackageNotFoundError,
639c673f3d0SPaolo Bonzini            distribution,
640c673f3d0SPaolo Bonzini        )
641c673f3d0SPaolo Bonzini
642c673f3d0SPaolo Bonzini    try:
643c673f3d0SPaolo Bonzini        return str(distribution(package).version)
644c673f3d0SPaolo Bonzini    except PackageNotFoundError:
645c673f3d0SPaolo Bonzini        return None
646c673f3d0SPaolo Bonzini
647c673f3d0SPaolo Bonzini
648c673f3d0SPaolo Bonzinidef _get_version_pkg_resources(package: str) -> Optional[str]:
649c673f3d0SPaolo Bonzini    # pylint: disable=import-outside-toplevel
650c673f3d0SPaolo Bonzini    # Bundled with setuptools; has a good chance of being available.
651c673f3d0SPaolo Bonzini    import pkg_resources
652c673f3d0SPaolo Bonzini
653c673f3d0SPaolo Bonzini    try:
654c673f3d0SPaolo Bonzini        return str(pkg_resources.get_distribution(package).version)
655c673f3d0SPaolo Bonzini    except pkg_resources.DistributionNotFound:
656c673f3d0SPaolo Bonzini        return None
657c673f3d0SPaolo Bonzini
658c673f3d0SPaolo Bonzini
659c673f3d0SPaolo Bonzinidef _get_version(package: str) -> Optional[str]:
660c673f3d0SPaolo Bonzini    try:
661c673f3d0SPaolo Bonzini        return _get_version_importlib(package)
662c673f3d0SPaolo Bonzini    except ImportError as exc:
663c673f3d0SPaolo Bonzini        logger.debug("%s", str(exc))
664c673f3d0SPaolo Bonzini
665c673f3d0SPaolo Bonzini    try:
666c673f3d0SPaolo Bonzini        return _get_version_pkg_resources(package)
667c673f3d0SPaolo Bonzini    except ImportError as exc:
668c673f3d0SPaolo Bonzini        logger.debug("%s", str(exc))
669c673f3d0SPaolo Bonzini        raise Ouch(
670c673f3d0SPaolo Bonzini            "Neither importlib.metadata nor pkg_resources found. "
671c673f3d0SPaolo Bonzini            "Use Python 3.8+, or install importlib-metadata or setuptools."
672c673f3d0SPaolo Bonzini        ) from exc
673c673f3d0SPaolo Bonzini
674c673f3d0SPaolo Bonzini
6754695a22eSJohn Snowdef diagnose(
6764695a22eSJohn Snow    dep_spec: str,
6774695a22eSJohn Snow    online: bool,
6784695a22eSJohn Snow    wheels_dir: Optional[Union[str, Path]],
6794695a22eSJohn Snow    prog: Optional[str],
6804695a22eSJohn Snow) -> Tuple[str, bool]:
6814695a22eSJohn Snow    """
6824695a22eSJohn Snow    Offer a summary to the user as to why a package failed to be installed.
6834695a22eSJohn Snow
6844695a22eSJohn Snow    :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5'
6854695a22eSJohn Snow    :param online: Did we allow PyPI access?
6864695a22eSJohn Snow    :param prog:
6874695a22eSJohn Snow        Optionally, a shell program name that can be used as a
6884695a22eSJohn Snow        bellwether to detect if this program is installed elsewhere on
6894695a22eSJohn Snow        the system. This is used to offer advice when a program is
6904695a22eSJohn Snow        detected for a different python version.
6914695a22eSJohn Snow    :param wheels_dir:
6924695a22eSJohn Snow        Optionally, a directory that was searched for vendored packages.
6934695a22eSJohn Snow    """
6944695a22eSJohn Snow    # pylint: disable=too-many-branches
6954695a22eSJohn Snow
6964695a22eSJohn Snow    # Some errors are not particularly serious
6974695a22eSJohn Snow    bad = False
6984695a22eSJohn Snow
6994695a22eSJohn Snow    pkg_name = pkgname_from_depspec(dep_spec)
700c673f3d0SPaolo Bonzini    pkg_version = _get_version(pkg_name)
7014695a22eSJohn Snow
7024695a22eSJohn Snow    lines = []
7034695a22eSJohn Snow
7044695a22eSJohn Snow    if pkg_version:
7054695a22eSJohn Snow        lines.append(
7064695a22eSJohn Snow            f"Python package '{pkg_name}' version '{pkg_version}' was found,"
7074695a22eSJohn Snow            " but isn't suitable."
7084695a22eSJohn Snow        )
7094695a22eSJohn Snow    else:
7104695a22eSJohn Snow        lines.append(
711c673f3d0SPaolo Bonzini            f"Python package '{pkg_name}' was not found nor installed."
7124695a22eSJohn Snow        )
7134695a22eSJohn Snow
7144695a22eSJohn Snow    if wheels_dir:
7154695a22eSJohn Snow        lines.append(
7164695a22eSJohn Snow            "No suitable version found in, or failed to install from"
7174695a22eSJohn Snow            f" '{wheels_dir}'."
7184695a22eSJohn Snow        )
7194695a22eSJohn Snow        bad = True
7204695a22eSJohn Snow
7214695a22eSJohn Snow    if online:
7224695a22eSJohn Snow        lines.append("A suitable version could not be obtained from PyPI.")
7234695a22eSJohn Snow        bad = True
7244695a22eSJohn Snow    else:
7254695a22eSJohn Snow        lines.append(
7264695a22eSJohn Snow            "mkvenv was configured to operate offline and did not check PyPI."
7274695a22eSJohn Snow        )
7284695a22eSJohn Snow
7294695a22eSJohn Snow    if prog and not pkg_version:
7304695a22eSJohn Snow        which = shutil.which(prog)
7314695a22eSJohn Snow        if which:
7324695a22eSJohn Snow            if sys.base_prefix in site.PREFIXES:
7334695a22eSJohn Snow                pypath = Path(sys.executable).resolve()
7344695a22eSJohn Snow                lines.append(
7354695a22eSJohn Snow                    f"'{prog}' was detected on your system at '{which}', "
7364695a22eSJohn Snow                    f"but the Python package '{pkg_name}' was not found by "
7374695a22eSJohn Snow                    f"this Python interpreter ('{pypath}'). "
7384695a22eSJohn Snow                    f"Typically this means that '{prog}' has been installed "
7394695a22eSJohn Snow                    "against a different Python interpreter on your system."
7404695a22eSJohn Snow                )
7414695a22eSJohn Snow            else:
7424695a22eSJohn Snow                lines.append(
7434695a22eSJohn Snow                    f"'{prog}' was detected on your system at '{which}', "
7444695a22eSJohn Snow                    "but the build is using an isolated virtual environment."
7454695a22eSJohn Snow                )
7464695a22eSJohn Snow            bad = True
7474695a22eSJohn Snow
7484695a22eSJohn Snow    lines = [f" • {line}" for line in lines]
7494695a22eSJohn Snow    if bad:
7504695a22eSJohn Snow        lines.insert(0, f"Could not provide build dependency '{dep_spec}':")
7514695a22eSJohn Snow    else:
7524695a22eSJohn Snow        lines.insert(0, f"'{dep_spec}' not found:")
7534695a22eSJohn Snow    return os.linesep.join(lines), bad
7544695a22eSJohn Snow
7554695a22eSJohn Snow
756c5538eedSJohn Snowdef pip_install(
757c5538eedSJohn Snow    args: Sequence[str],
758c5538eedSJohn Snow    online: bool = False,
759c5538eedSJohn Snow    wheels_dir: Optional[Union[str, Path]] = None,
760c5538eedSJohn Snow) -> None:
761c5538eedSJohn Snow    """
762c5538eedSJohn Snow    Use pip to install a package or package(s) as specified in @args.
763c5538eedSJohn Snow    """
764c5538eedSJohn Snow    loud = bool(
765c5538eedSJohn Snow        os.environ.get("DEBUG")
766c5538eedSJohn Snow        or os.environ.get("GITLAB_CI")
767c5538eedSJohn Snow        or os.environ.get("V")
768c5538eedSJohn Snow    )
769c5538eedSJohn Snow
770c5538eedSJohn Snow    full_args = [
771c5538eedSJohn Snow        sys.executable,
772c5538eedSJohn Snow        "-m",
773c5538eedSJohn Snow        "pip",
774c5538eedSJohn Snow        "install",
775c5538eedSJohn Snow        "--disable-pip-version-check",
776c5538eedSJohn Snow        "-v" if loud else "-q",
777c5538eedSJohn Snow    ]
778c5538eedSJohn Snow    if not online:
779c5538eedSJohn Snow        full_args += ["--no-index"]
780c5538eedSJohn Snow    if wheels_dir:
781c5538eedSJohn Snow        full_args += ["--find-links", f"file://{str(wheels_dir)}"]
782c5538eedSJohn Snow    full_args += list(args)
783c5538eedSJohn Snow    subprocess.run(
784c5538eedSJohn Snow        full_args,
785c5538eedSJohn Snow        check=True,
786c5538eedSJohn Snow    )
787c5538eedSJohn Snow
788c5538eedSJohn Snow
7894695a22eSJohn Snowdef _do_ensure(
790c5538eedSJohn Snow    dep_specs: Sequence[str],
791c5538eedSJohn Snow    online: bool = False,
792c5538eedSJohn Snow    wheels_dir: Optional[Union[str, Path]] = None,
793d37c21b5SPaolo Bonzini    prog: Optional[str] = None,
794d37c21b5SPaolo Bonzini) -> Optional[Tuple[str, bool]]:
795c5538eedSJohn Snow    """
796c5538eedSJohn Snow    Use pip to ensure we have the package specified by @dep_specs.
797c5538eedSJohn Snow
798c5538eedSJohn Snow    If the package is already installed, do nothing. If online and
799c5538eedSJohn Snow    wheels_dir are both provided, prefer packages found in wheels_dir
800c5538eedSJohn Snow    first before connecting to PyPI.
801c5538eedSJohn Snow
802c5538eedSJohn Snow    :param dep_specs:
803c5538eedSJohn Snow        PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
804c5538eedSJohn Snow    :param online: If True, fall back to PyPI.
805c5538eedSJohn Snow    :param wheels_dir: If specified, search this path for packages.
806c5538eedSJohn Snow    """
807c5538eedSJohn Snow    absent = []
80892834894SJohn Snow    present = []
809c5538eedSJohn Snow    for spec in dep_specs:
810c5538eedSJohn Snow        matcher = distlib.version.LegacyMatcher(spec)
811c673f3d0SPaolo Bonzini        ver = _get_version(matcher.name)
812*47a90a51SPaolo Bonzini        if (
813*47a90a51SPaolo Bonzini            ver is None
814*47a90a51SPaolo Bonzini            # Always pass installed package to pip, so that they can be
815*47a90a51SPaolo Bonzini            # updated if the requested version changes
816*47a90a51SPaolo Bonzini            or not _is_system_package(matcher.name)
817*47a90a51SPaolo Bonzini            or not matcher.match(distlib.version.LegacyVersion(ver))
818c673f3d0SPaolo Bonzini        ):
819c5538eedSJohn Snow            absent.append(spec)
820c5538eedSJohn Snow        else:
821c673f3d0SPaolo Bonzini            logger.info("found %s %s", matcher.name, ver)
82292834894SJohn Snow            present.append(matcher.name)
82392834894SJohn Snow
82492834894SJohn Snow    if present:
82592834894SJohn Snow        generate_console_scripts(present)
826c5538eedSJohn Snow
827c5538eedSJohn Snow    if absent:
828d37c21b5SPaolo Bonzini        if online or wheels_dir:
829c5538eedSJohn Snow            # Some packages are missing or aren't a suitable version,
830c5538eedSJohn Snow            # install a suitable (possibly vendored) package.
831c5538eedSJohn Snow            print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr)
832d37c21b5SPaolo Bonzini            try:
833c5538eedSJohn Snow                pip_install(args=absent, online=online, wheels_dir=wheels_dir)
834d37c21b5SPaolo Bonzini                return None
835d37c21b5SPaolo Bonzini            except subprocess.CalledProcessError:
836d37c21b5SPaolo Bonzini                pass
837d37c21b5SPaolo Bonzini
838d37c21b5SPaolo Bonzini        return diagnose(
839d37c21b5SPaolo Bonzini            absent[0],
840d37c21b5SPaolo Bonzini            online,
841d37c21b5SPaolo Bonzini            wheels_dir,
842d37c21b5SPaolo Bonzini            prog if absent[0] == dep_specs[0] else None,
843d37c21b5SPaolo Bonzini        )
844d37c21b5SPaolo Bonzini
845d37c21b5SPaolo Bonzini    return None
846c5538eedSJohn Snow
847c5538eedSJohn Snow
8484695a22eSJohn Snowdef ensure(
8494695a22eSJohn Snow    dep_specs: Sequence[str],
8504695a22eSJohn Snow    online: bool = False,
8514695a22eSJohn Snow    wheels_dir: Optional[Union[str, Path]] = None,
8524695a22eSJohn Snow    prog: Optional[str] = None,
8534695a22eSJohn Snow) -> None:
8544695a22eSJohn Snow    """
8554695a22eSJohn Snow    Use pip to ensure we have the package specified by @dep_specs.
8564695a22eSJohn Snow
8574695a22eSJohn Snow    If the package is already installed, do nothing. If online and
8584695a22eSJohn Snow    wheels_dir are both provided, prefer packages found in wheels_dir
8594695a22eSJohn Snow    first before connecting to PyPI.
8604695a22eSJohn Snow
8614695a22eSJohn Snow    :param dep_specs:
8624695a22eSJohn Snow        PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
8634695a22eSJohn Snow    :param online: If True, fall back to PyPI.
8644695a22eSJohn Snow    :param wheels_dir: If specified, search this path for packages.
8654695a22eSJohn Snow    :param prog:
8664695a22eSJohn Snow        If specified, use this program name for error diagnostics that will
8674695a22eSJohn Snow        be presented to the user. e.g., 'sphinx-build' can be used as a
8684695a22eSJohn Snow        bellwether for the presence of 'sphinx'.
8694695a22eSJohn Snow    """
8704695a22eSJohn Snow    print(f"mkvenv: checking for {', '.join(dep_specs)}", file=sys.stderr)
87168ea6d17SJohn Snow
87268ea6d17SJohn Snow    if not HAVE_DISTLIB:
87368ea6d17SJohn Snow        raise Ouch("a usable distlib could not be found, please install it")
87468ea6d17SJohn Snow
875d37c21b5SPaolo Bonzini    result = _do_ensure(dep_specs, online, wheels_dir, prog)
876d37c21b5SPaolo Bonzini    if result:
8774695a22eSJohn Snow        # Well, that's not good.
878d37c21b5SPaolo Bonzini        if result[1]:
879d37c21b5SPaolo Bonzini            raise Ouch(result[0])
880d37c21b5SPaolo Bonzini        raise SystemExit(f"\n{result[0]}\n\n")
8814695a22eSJohn Snow
8824695a22eSJohn Snow
883f1ad527fSJohn Snowdef post_venv_setup() -> None:
884f1ad527fSJohn Snow    """
885f1ad527fSJohn Snow    This is intended to be run *inside the venv* after it is created.
886f1ad527fSJohn Snow    """
887f1ad527fSJohn Snow    logger.debug("post_venv_setup()")
888c8049626SJohn Snow    # Test for a broken pip (Debian 10 or derivative?) and fix it if needed
889c8049626SJohn Snow    if not checkpip():
890c8049626SJohn Snow        # Finally, generate a 'pip' script so the venv is usable in a normal
891f1ad527fSJohn Snow        # way from the CLI. This only happens when we inherited pip from a
892f1ad527fSJohn Snow        # parent/system-site and haven't run ensurepip in some way.
893f1ad527fSJohn Snow        generate_console_scripts(["pip"])
894f1ad527fSJohn Snow
895f1ad527fSJohn Snow
896dd84028fSJohn Snowdef _add_create_subcommand(subparsers: Any) -> None:
897dd84028fSJohn Snow    subparser = subparsers.add_parser("create", help="create a venv")
898dd84028fSJohn Snow    subparser.add_argument(
899dd84028fSJohn Snow        "target",
900dd84028fSJohn Snow        type=str,
901dd84028fSJohn Snow        action="store",
902dd84028fSJohn Snow        help="Target directory to install virtual environment into.",
903dd84028fSJohn Snow    )
904dd84028fSJohn Snow
905dd84028fSJohn Snow
906f1ad527fSJohn Snowdef _add_post_init_subcommand(subparsers: Any) -> None:
907f1ad527fSJohn Snow    subparsers.add_parser("post_init", help="post-venv initialization")
908f1ad527fSJohn Snow
909f1ad527fSJohn Snow
910c5538eedSJohn Snowdef _add_ensure_subcommand(subparsers: Any) -> None:
911c5538eedSJohn Snow    subparser = subparsers.add_parser(
912c5538eedSJohn Snow        "ensure", help="Ensure that the specified package is installed."
913c5538eedSJohn Snow    )
914c5538eedSJohn Snow    subparser.add_argument(
915c5538eedSJohn Snow        "--online",
916c5538eedSJohn Snow        action="store_true",
917c5538eedSJohn Snow        help="Install packages from PyPI, if necessary.",
918c5538eedSJohn Snow    )
919c5538eedSJohn Snow    subparser.add_argument(
920c5538eedSJohn Snow        "--dir",
921c5538eedSJohn Snow        type=str,
922c5538eedSJohn Snow        action="store",
923c5538eedSJohn Snow        help="Path to vendored packages where we may install from.",
924c5538eedSJohn Snow    )
925c5538eedSJohn Snow    subparser.add_argument(
9264695a22eSJohn Snow        "--diagnose",
9274695a22eSJohn Snow        type=str,
9284695a22eSJohn Snow        action="store",
9294695a22eSJohn Snow        help=(
9304695a22eSJohn Snow            "Name of a shell utility to use for "
9314695a22eSJohn Snow            "diagnostics if this command fails."
9324695a22eSJohn Snow        ),
9334695a22eSJohn Snow    )
9344695a22eSJohn Snow    subparser.add_argument(
935c5538eedSJohn Snow        "dep_specs",
936c5538eedSJohn Snow        type=str,
937c5538eedSJohn Snow        action="store",
938c5538eedSJohn Snow        help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'",
939c5538eedSJohn Snow        nargs="+",
940c5538eedSJohn Snow    )
941c5538eedSJohn Snow
942c5538eedSJohn Snow
943dd84028fSJohn Snowdef main() -> int:
944dd84028fSJohn Snow    """CLI interface to make_qemu_venv. See module docstring."""
945dd84028fSJohn Snow    if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"):
946dd84028fSJohn Snow        # You're welcome.
947dd84028fSJohn Snow        logging.basicConfig(level=logging.DEBUG)
948c5538eedSJohn Snow    else:
949c5538eedSJohn Snow        if os.environ.get("V"):
950dd84028fSJohn Snow            logging.basicConfig(level=logging.INFO)
951dd84028fSJohn Snow
952dd84028fSJohn Snow    parser = argparse.ArgumentParser(
953dd84028fSJohn Snow        prog="mkvenv",
954dd84028fSJohn Snow        description="QEMU pyvenv bootstrapping utility",
955dd84028fSJohn Snow    )
956dd84028fSJohn Snow    subparsers = parser.add_subparsers(
957dd84028fSJohn Snow        title="Commands",
958dd84028fSJohn Snow        dest="command",
95902312f1aSPaolo Bonzini        required=True,
960dd84028fSJohn Snow        metavar="command",
961dd84028fSJohn Snow        help="Description",
962dd84028fSJohn Snow    )
963dd84028fSJohn Snow
964dd84028fSJohn Snow    _add_create_subcommand(subparsers)
965f1ad527fSJohn Snow    _add_post_init_subcommand(subparsers)
966c5538eedSJohn Snow    _add_ensure_subcommand(subparsers)
967dd84028fSJohn Snow
968dd84028fSJohn Snow    args = parser.parse_args()
969dd84028fSJohn Snow    try:
970dd84028fSJohn Snow        if args.command == "create":
971dd84028fSJohn Snow            make_venv(
972dd84028fSJohn Snow                args.target,
973dd84028fSJohn Snow                system_site_packages=True,
974dd84028fSJohn Snow                clear=True,
975dd84028fSJohn Snow            )
976f1ad527fSJohn Snow        if args.command == "post_init":
977f1ad527fSJohn Snow            post_venv_setup()
978c5538eedSJohn Snow        if args.command == "ensure":
979c5538eedSJohn Snow            ensure(
980c5538eedSJohn Snow                dep_specs=args.dep_specs,
981c5538eedSJohn Snow                online=args.online,
982c5538eedSJohn Snow                wheels_dir=args.dir,
9834695a22eSJohn Snow                prog=args.diagnose,
984c5538eedSJohn Snow            )
985dd84028fSJohn Snow        logger.debug("mkvenv.py %s: exiting", args.command)
986dd84028fSJohn Snow    except Ouch as exc:
987dd84028fSJohn Snow        print("\n*** Ouch! ***\n", file=sys.stderr)
988dd84028fSJohn Snow        print(str(exc), "\n\n", file=sys.stderr)
989dd84028fSJohn Snow        return 1
990dd84028fSJohn Snow    except SystemExit:
991dd84028fSJohn Snow        raise
992dd84028fSJohn Snow    except:  # pylint: disable=bare-except
993dd84028fSJohn Snow        logger.exception("mkvenv did not complete successfully:")
994dd84028fSJohn Snow        return 2
995dd84028fSJohn Snow    return 0
996dd84028fSJohn Snow
997dd84028fSJohn Snow
998dd84028fSJohn Snowif __name__ == "__main__":
999dd84028fSJohn Snow    sys.exit(main())
1000