xref: /qemu/python/scripts/mkvenv.py (revision dee01b827ffc26577217697074052b8b7f4770dc)
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
14dd84028fSJohn Snow
15dd84028fSJohn Snow--------------------------------------------------
16dd84028fSJohn Snow
17dd84028fSJohn Snowusage: mkvenv create [-h] target
18dd84028fSJohn Snow
19dd84028fSJohn Snowpositional arguments:
20dd84028fSJohn Snow  target      Target directory to install virtual environment into.
21dd84028fSJohn Snow
22dd84028fSJohn Snowoptions:
23dd84028fSJohn Snow  -h, --help  show this help message and exit
24dd84028fSJohn Snow
25dd84028fSJohn Snow"""
26dd84028fSJohn Snow
27dd84028fSJohn Snow# Copyright (C) 2022-2023 Red Hat, Inc.
28dd84028fSJohn Snow#
29dd84028fSJohn Snow# Authors:
30dd84028fSJohn Snow#  John Snow <jsnow@redhat.com>
31dd84028fSJohn Snow#  Paolo Bonzini <pbonzini@redhat.com>
32dd84028fSJohn Snow#
33dd84028fSJohn Snow# This work is licensed under the terms of the GNU GPL, version 2 or
34dd84028fSJohn Snow# later. See the COPYING file in the top-level directory.
35dd84028fSJohn Snow
36dd84028fSJohn Snowimport argparse
37a9dbde71SJohn Snowfrom importlib.util import find_spec
38dd84028fSJohn Snowimport logging
39dd84028fSJohn Snowimport os
40dd84028fSJohn Snowfrom pathlib import Path
41*dee01b82SJohn Snowimport site
42dd84028fSJohn Snowimport subprocess
43dd84028fSJohn Snowimport sys
44*dee01b82SJohn Snowimport sysconfig
45dd84028fSJohn Snowfrom types import SimpleNamespace
46dd84028fSJohn Snowfrom typing import Any, Optional, Union
47dd84028fSJohn Snowimport venv
48dd84028fSJohn Snow
49dd84028fSJohn Snow
50dd84028fSJohn Snow# Do not add any mandatory dependencies from outside the stdlib:
51dd84028fSJohn Snow# This script *must* be usable standalone!
52dd84028fSJohn Snow
53dd84028fSJohn SnowDirType = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"]
54dd84028fSJohn Snowlogger = logging.getLogger("mkvenv")
55dd84028fSJohn Snow
56dd84028fSJohn Snow
57*dee01b82SJohn Snowdef inside_a_venv() -> bool:
58*dee01b82SJohn Snow    """Returns True if it is executed inside of a virtual environment."""
59*dee01b82SJohn Snow    return sys.prefix != sys.base_prefix
60*dee01b82SJohn Snow
61*dee01b82SJohn Snow
62dd84028fSJohn Snowclass Ouch(RuntimeError):
63dd84028fSJohn Snow    """An Exception class we can't confuse with a builtin."""
64dd84028fSJohn Snow
65dd84028fSJohn Snow
66dd84028fSJohn Snowclass QemuEnvBuilder(venv.EnvBuilder):
67dd84028fSJohn Snow    """
68dd84028fSJohn Snow    An extension of venv.EnvBuilder for building QEMU's configure-time venv.
69dd84028fSJohn Snow
70*dee01b82SJohn Snow    The primary difference is that it emulates a "nested" virtual
71*dee01b82SJohn Snow    environment when invoked from inside of an existing virtual
72*dee01b82SJohn Snow    environment by including packages from the parent.
73dd84028fSJohn Snow
74dd84028fSJohn Snow    Parameters for base class init:
75dd84028fSJohn Snow      - system_site_packages: bool = False
76dd84028fSJohn Snow      - clear: bool = False
77dd84028fSJohn Snow      - symlinks: bool = False
78dd84028fSJohn Snow      - upgrade: bool = False
79dd84028fSJohn Snow      - with_pip: bool = False
80dd84028fSJohn Snow      - prompt: Optional[str] = None
81dd84028fSJohn Snow      - upgrade_deps: bool = False             (Since 3.9)
82dd84028fSJohn Snow    """
83dd84028fSJohn Snow
84dd84028fSJohn Snow    def __init__(self, *args: Any, **kwargs: Any) -> None:
85dd84028fSJohn Snow        logger.debug("QemuEnvBuilder.__init__(...)")
86a9dbde71SJohn Snow
87*dee01b82SJohn Snow        # For nested venv emulation:
88*dee01b82SJohn Snow        self.use_parent_packages = False
89*dee01b82SJohn Snow        if inside_a_venv():
90*dee01b82SJohn Snow            # Include parent packages only if we're in a venv and
91*dee01b82SJohn Snow            # system_site_packages was True.
92*dee01b82SJohn Snow            self.use_parent_packages = kwargs.pop(
93*dee01b82SJohn Snow                "system_site_packages", False
94*dee01b82SJohn Snow            )
95*dee01b82SJohn Snow            # Include system_site_packages only when the parent,
96*dee01b82SJohn Snow            # The venv we are currently in, also does so.
97*dee01b82SJohn Snow            kwargs["system_site_packages"] = sys.base_prefix in site.PREFIXES
98*dee01b82SJohn Snow
99a9dbde71SJohn Snow        if kwargs.get("with_pip", False):
100a9dbde71SJohn Snow            check_ensurepip()
101a9dbde71SJohn Snow
102dd84028fSJohn Snow        super().__init__(*args, **kwargs)
103dd84028fSJohn Snow
104dd84028fSJohn Snow        # Make the context available post-creation:
105dd84028fSJohn Snow        self._context: Optional[SimpleNamespace] = None
106dd84028fSJohn Snow
107*dee01b82SJohn Snow    def get_parent_libpath(self) -> Optional[str]:
108*dee01b82SJohn Snow        """Return the libpath of the parent venv, if applicable."""
109*dee01b82SJohn Snow        if self.use_parent_packages:
110*dee01b82SJohn Snow            return sysconfig.get_path("purelib")
111*dee01b82SJohn Snow        return None
112*dee01b82SJohn Snow
113*dee01b82SJohn Snow    @staticmethod
114*dee01b82SJohn Snow    def compute_venv_libpath(context: SimpleNamespace) -> str:
115*dee01b82SJohn Snow        """
116*dee01b82SJohn Snow        Compatibility wrapper for context.lib_path for Python < 3.12
117*dee01b82SJohn Snow        """
118*dee01b82SJohn Snow        # Python 3.12+, not strictly necessary because it's documented
119*dee01b82SJohn Snow        # to be the same as 3.10 code below:
120*dee01b82SJohn Snow        if sys.version_info >= (3, 12):
121*dee01b82SJohn Snow            return context.lib_path
122*dee01b82SJohn Snow
123*dee01b82SJohn Snow        # Python 3.10+
124*dee01b82SJohn Snow        if "venv" in sysconfig.get_scheme_names():
125*dee01b82SJohn Snow            lib_path = sysconfig.get_path(
126*dee01b82SJohn Snow                "purelib", scheme="venv", vars={"base": context.env_dir}
127*dee01b82SJohn Snow            )
128*dee01b82SJohn Snow            assert lib_path is not None
129*dee01b82SJohn Snow            return lib_path
130*dee01b82SJohn Snow
131*dee01b82SJohn Snow        # For Python <= 3.9 we need to hardcode this. Fortunately the
132*dee01b82SJohn Snow        # code below was the same in Python 3.6-3.10, so there is only
133*dee01b82SJohn Snow        # one case.
134*dee01b82SJohn Snow        if sys.platform == "win32":
135*dee01b82SJohn Snow            return os.path.join(context.env_dir, "Lib", "site-packages")
136*dee01b82SJohn Snow        return os.path.join(
137*dee01b82SJohn Snow            context.env_dir,
138*dee01b82SJohn Snow            "lib",
139*dee01b82SJohn Snow            "python%d.%d" % sys.version_info[:2],
140*dee01b82SJohn Snow            "site-packages",
141*dee01b82SJohn Snow        )
142*dee01b82SJohn Snow
143dd84028fSJohn Snow    def ensure_directories(self, env_dir: DirType) -> SimpleNamespace:
144dd84028fSJohn Snow        logger.debug("ensure_directories(env_dir=%s)", env_dir)
145dd84028fSJohn Snow        self._context = super().ensure_directories(env_dir)
146dd84028fSJohn Snow        return self._context
147dd84028fSJohn Snow
148*dee01b82SJohn Snow    def create(self, env_dir: DirType) -> None:
149*dee01b82SJohn Snow        logger.debug("create(env_dir=%s)", env_dir)
150*dee01b82SJohn Snow        super().create(env_dir)
151*dee01b82SJohn Snow        assert self._context is not None
152*dee01b82SJohn Snow        self.post_post_setup(self._context)
153*dee01b82SJohn Snow
154*dee01b82SJohn Snow    def post_post_setup(self, context: SimpleNamespace) -> None:
155*dee01b82SJohn Snow        """
156*dee01b82SJohn Snow        The final, final hook. Enter the venv and run commands inside of it.
157*dee01b82SJohn Snow        """
158*dee01b82SJohn Snow        if self.use_parent_packages:
159*dee01b82SJohn Snow            # We're inside of a venv and we want to include the parent
160*dee01b82SJohn Snow            # venv's packages.
161*dee01b82SJohn Snow            parent_libpath = self.get_parent_libpath()
162*dee01b82SJohn Snow            assert parent_libpath is not None
163*dee01b82SJohn Snow            logger.debug("parent_libpath: %s", parent_libpath)
164*dee01b82SJohn Snow
165*dee01b82SJohn Snow            our_libpath = self.compute_venv_libpath(context)
166*dee01b82SJohn Snow            logger.debug("our_libpath: %s", our_libpath)
167*dee01b82SJohn Snow
168*dee01b82SJohn Snow            pth_file = os.path.join(our_libpath, "nested.pth")
169*dee01b82SJohn Snow            with open(pth_file, "w", encoding="UTF-8") as file:
170*dee01b82SJohn Snow                file.write(parent_libpath + os.linesep)
171*dee01b82SJohn Snow
172dd84028fSJohn Snow    def get_value(self, field: str) -> str:
173dd84028fSJohn Snow        """
174dd84028fSJohn Snow        Get a string value from the context namespace after a call to build.
175dd84028fSJohn Snow
176dd84028fSJohn Snow        For valid field names, see:
177dd84028fSJohn Snow        https://docs.python.org/3/library/venv.html#venv.EnvBuilder.ensure_directories
178dd84028fSJohn Snow        """
179dd84028fSJohn Snow        ret = getattr(self._context, field)
180dd84028fSJohn Snow        assert isinstance(ret, str)
181dd84028fSJohn Snow        return ret
182dd84028fSJohn Snow
183dd84028fSJohn Snow
184a9dbde71SJohn Snowdef check_ensurepip() -> None:
185a9dbde71SJohn Snow    """
186a9dbde71SJohn Snow    Check that we have ensurepip.
187a9dbde71SJohn Snow
188a9dbde71SJohn Snow    Raise a fatal exception with a helpful hint if it isn't available.
189a9dbde71SJohn Snow    """
190a9dbde71SJohn Snow    if not find_spec("ensurepip"):
191a9dbde71SJohn Snow        msg = (
192a9dbde71SJohn Snow            "Python's ensurepip module is not found.\n"
193a9dbde71SJohn Snow            "It's normally part of the Python standard library, "
194a9dbde71SJohn Snow            "maybe your distribution packages it separately?\n"
195a9dbde71SJohn Snow            "Either install ensurepip, or alleviate the need for it in the "
196a9dbde71SJohn Snow            "first place by installing pip and setuptools for "
197a9dbde71SJohn Snow            f"'{sys.executable}'.\n"
198a9dbde71SJohn Snow            "(Hint: Debian puts ensurepip in its python3-venv package.)"
199a9dbde71SJohn Snow        )
200a9dbde71SJohn Snow        raise Ouch(msg)
201a9dbde71SJohn Snow
202a9dbde71SJohn Snow    # ensurepip uses pyexpat, which can also go missing on us:
203a9dbde71SJohn Snow    if not find_spec("pyexpat"):
204a9dbde71SJohn Snow        msg = (
205a9dbde71SJohn Snow            "Python's pyexpat module is not found.\n"
206a9dbde71SJohn Snow            "It's normally part of the Python standard library, "
207a9dbde71SJohn Snow            "maybe your distribution packages it separately?\n"
208a9dbde71SJohn Snow            "Either install pyexpat, or alleviate the need for it in the "
209a9dbde71SJohn Snow            "first place by installing pip and setuptools for "
210a9dbde71SJohn Snow            f"'{sys.executable}'.\n\n"
211a9dbde71SJohn Snow            "(Hint: NetBSD's pkgsrc debundles this to e.g. 'py310-expat'.)"
212a9dbde71SJohn Snow        )
213a9dbde71SJohn Snow        raise Ouch(msg)
214a9dbde71SJohn Snow
215a9dbde71SJohn Snow
216dd84028fSJohn Snowdef make_venv(  # pylint: disable=too-many-arguments
217dd84028fSJohn Snow    env_dir: Union[str, Path],
218dd84028fSJohn Snow    system_site_packages: bool = False,
219dd84028fSJohn Snow    clear: bool = True,
220dd84028fSJohn Snow    symlinks: Optional[bool] = None,
221dd84028fSJohn Snow    with_pip: bool = True,
222dd84028fSJohn Snow) -> None:
223dd84028fSJohn Snow    """
224dd84028fSJohn Snow    Create a venv using `QemuEnvBuilder`.
225dd84028fSJohn Snow
226dd84028fSJohn Snow    This is analogous to the `venv.create` module-level convenience
227dd84028fSJohn Snow    function that is part of the Python stdblib, except it uses
228dd84028fSJohn Snow    `QemuEnvBuilder` instead.
229dd84028fSJohn Snow
230dd84028fSJohn Snow    :param env_dir: The directory to create/install to.
231dd84028fSJohn Snow    :param system_site_packages:
232dd84028fSJohn Snow        Allow inheriting packages from the system installation.
233dd84028fSJohn Snow    :param clear: When True, fully remove any prior venv and files.
234dd84028fSJohn Snow    :param symlinks:
235dd84028fSJohn Snow        Whether to use symlinks to the target interpreter or not. If
236dd84028fSJohn Snow        left unspecified, it will use symlinks except on Windows to
237dd84028fSJohn Snow        match behavior with the "venv" CLI tool.
238dd84028fSJohn Snow    :param with_pip:
239dd84028fSJohn Snow        Whether to install "pip" binaries or not.
240dd84028fSJohn Snow    """
241dd84028fSJohn Snow    logger.debug(
242dd84028fSJohn Snow        "%s: make_venv(env_dir=%s, system_site_packages=%s, "
243dd84028fSJohn Snow        "clear=%s, symlinks=%s, with_pip=%s)",
244dd84028fSJohn Snow        __file__,
245dd84028fSJohn Snow        str(env_dir),
246dd84028fSJohn Snow        system_site_packages,
247dd84028fSJohn Snow        clear,
248dd84028fSJohn Snow        symlinks,
249dd84028fSJohn Snow        with_pip,
250dd84028fSJohn Snow    )
251dd84028fSJohn Snow
252dd84028fSJohn Snow    if symlinks is None:
253dd84028fSJohn Snow        # Default behavior of standard venv CLI
254dd84028fSJohn Snow        symlinks = os.name != "nt"
255dd84028fSJohn Snow
256dd84028fSJohn Snow    builder = QemuEnvBuilder(
257dd84028fSJohn Snow        system_site_packages=system_site_packages,
258dd84028fSJohn Snow        clear=clear,
259dd84028fSJohn Snow        symlinks=symlinks,
260dd84028fSJohn Snow        with_pip=with_pip,
261dd84028fSJohn Snow    )
262dd84028fSJohn Snow
263dd84028fSJohn Snow    style = "non-isolated" if builder.system_site_packages else "isolated"
264*dee01b82SJohn Snow    nested = ""
265*dee01b82SJohn Snow    if builder.use_parent_packages:
266*dee01b82SJohn Snow        nested = f"(with packages from '{builder.get_parent_libpath()}') "
267dd84028fSJohn Snow    print(
268dd84028fSJohn Snow        f"mkvenv: Creating {style} virtual environment"
269*dee01b82SJohn Snow        f" {nested}at '{str(env_dir)}'",
270dd84028fSJohn Snow        file=sys.stderr,
271dd84028fSJohn Snow    )
272dd84028fSJohn Snow
273dd84028fSJohn Snow    try:
274dd84028fSJohn Snow        logger.debug("Invoking builder.create()")
275dd84028fSJohn Snow        try:
276dd84028fSJohn Snow            builder.create(str(env_dir))
277dd84028fSJohn Snow        except SystemExit as exc:
278dd84028fSJohn Snow            # Some versions of the venv module raise SystemExit; *nasty*!
279dd84028fSJohn Snow            # We want the exception that prompted it. It might be a subprocess
280dd84028fSJohn Snow            # error that has output we *really* want to see.
281dd84028fSJohn Snow            logger.debug("Intercepted SystemExit from EnvBuilder.create()")
282dd84028fSJohn Snow            raise exc.__cause__ or exc.__context__ or exc
283dd84028fSJohn Snow        logger.debug("builder.create() finished")
284dd84028fSJohn Snow    except subprocess.CalledProcessError as exc:
285dd84028fSJohn Snow        logger.error("mkvenv subprocess failed:")
286dd84028fSJohn Snow        logger.error("cmd: %s", exc.cmd)
287dd84028fSJohn Snow        logger.error("returncode: %d", exc.returncode)
288dd84028fSJohn Snow
289dd84028fSJohn Snow        def _stringify(data: Union[str, bytes]) -> str:
290dd84028fSJohn Snow            if isinstance(data, bytes):
291dd84028fSJohn Snow                return data.decode()
292dd84028fSJohn Snow            return data
293dd84028fSJohn Snow
294dd84028fSJohn Snow        lines = []
295dd84028fSJohn Snow        if exc.stdout:
296dd84028fSJohn Snow            lines.append("========== stdout ==========")
297dd84028fSJohn Snow            lines.append(_stringify(exc.stdout))
298dd84028fSJohn Snow            lines.append("============================")
299dd84028fSJohn Snow        if exc.stderr:
300dd84028fSJohn Snow            lines.append("========== stderr ==========")
301dd84028fSJohn Snow            lines.append(_stringify(exc.stderr))
302dd84028fSJohn Snow            lines.append("============================")
303dd84028fSJohn Snow        if lines:
304dd84028fSJohn Snow            logger.error(os.linesep.join(lines))
305dd84028fSJohn Snow
306dd84028fSJohn Snow        raise Ouch("VENV creation subprocess failed.") from exc
307dd84028fSJohn Snow
308dd84028fSJohn Snow    # print the python executable to stdout for configure.
309dd84028fSJohn Snow    print(builder.get_value("env_exe"))
310dd84028fSJohn Snow
311dd84028fSJohn Snow
312dd84028fSJohn Snowdef _add_create_subcommand(subparsers: Any) -> None:
313dd84028fSJohn Snow    subparser = subparsers.add_parser("create", help="create a venv")
314dd84028fSJohn Snow    subparser.add_argument(
315dd84028fSJohn Snow        "target",
316dd84028fSJohn Snow        type=str,
317dd84028fSJohn Snow        action="store",
318dd84028fSJohn Snow        help="Target directory to install virtual environment into.",
319dd84028fSJohn Snow    )
320dd84028fSJohn Snow
321dd84028fSJohn Snow
322dd84028fSJohn Snowdef main() -> int:
323dd84028fSJohn Snow    """CLI interface to make_qemu_venv. See module docstring."""
324dd84028fSJohn Snow    if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"):
325dd84028fSJohn Snow        # You're welcome.
326dd84028fSJohn Snow        logging.basicConfig(level=logging.DEBUG)
327dd84028fSJohn Snow    elif os.environ.get("V"):
328dd84028fSJohn Snow        logging.basicConfig(level=logging.INFO)
329dd84028fSJohn Snow
330dd84028fSJohn Snow    parser = argparse.ArgumentParser(
331dd84028fSJohn Snow        prog="mkvenv",
332dd84028fSJohn Snow        description="QEMU pyvenv bootstrapping utility",
333dd84028fSJohn Snow    )
334dd84028fSJohn Snow    subparsers = parser.add_subparsers(
335dd84028fSJohn Snow        title="Commands",
336dd84028fSJohn Snow        dest="command",
337dd84028fSJohn Snow        metavar="command",
338dd84028fSJohn Snow        help="Description",
339dd84028fSJohn Snow    )
340dd84028fSJohn Snow
341dd84028fSJohn Snow    _add_create_subcommand(subparsers)
342dd84028fSJohn Snow
343dd84028fSJohn Snow    args = parser.parse_args()
344dd84028fSJohn Snow    try:
345dd84028fSJohn Snow        if args.command == "create":
346dd84028fSJohn Snow            make_venv(
347dd84028fSJohn Snow                args.target,
348dd84028fSJohn Snow                system_site_packages=True,
349dd84028fSJohn Snow                clear=True,
350dd84028fSJohn Snow            )
351dd84028fSJohn Snow        logger.debug("mkvenv.py %s: exiting", args.command)
352dd84028fSJohn Snow    except Ouch as exc:
353dd84028fSJohn Snow        print("\n*** Ouch! ***\n", file=sys.stderr)
354dd84028fSJohn Snow        print(str(exc), "\n\n", file=sys.stderr)
355dd84028fSJohn Snow        return 1
356dd84028fSJohn Snow    except SystemExit:
357dd84028fSJohn Snow        raise
358dd84028fSJohn Snow    except:  # pylint: disable=bare-except
359dd84028fSJohn Snow        logger.exception("mkvenv did not complete successfully:")
360dd84028fSJohn Snow        return 2
361dd84028fSJohn Snow    return 0
362dd84028fSJohn Snow
363dd84028fSJohn Snow
364dd84028fSJohn Snowif __name__ == "__main__":
365dd84028fSJohn Snow    sys.exit(main())
366