2 mkvenv - QEMU pyvenv bootstrapping utility
4 usage: mkvenv [-h] command ...
6 QEMU pyvenv bootstrapping utility
9 -h, --help show this help message and exit
15 post-venv initialization
16 ensure Ensure that the specified package is installed.
18 Ensure that the specified package group is installed.
20 --------------------------------------------------
22 usage: mkvenv create [-h] target
25 target Target directory to install virtual environment into.
28 -h, --help show this help message and exit
30 --------------------------------------------------
32 usage: mkvenv post_init [-h]
35 -h, --help show this help message and exit
37 --------------------------------------------------
39 usage: mkvenv ensure [-h] [--online] [--dir DIR] dep_spec...
42 dep_spec PEP 508 Dependency specification, e.g. 'meson>=0.61.5'
45 -h, --help show this help message and exit
46 --online Install packages from PyPI, if necessary.
47 --dir DIR Path to vendored packages where we may install from.
49 --------------------------------------------------
51 usage: mkvenv ensuregroup [-h] [--online] [--dir DIR] file group...
54 file pointer to a TOML file
55 group section name in the TOML file
58 -h, --help show this help message and exit
59 --online Install packages from PyPI, if necessary.
60 --dir DIR Path to vendored packages where we may install from.
64 # Copyright (C) 2022-2023 Red Hat, Inc.
67 # John Snow <jsnow@redhat.com>
68 # Paolo Bonzini <pbonzini@redhat.com>
70 # This work is licensed under the terms of the GNU GPL, version 2 or
71 # later. See the COPYING file in the top-level directory.
74 from importlib
.metadata
import (
81 from importlib
.util
import find_spec
84 from pathlib
import Path
91 from types
import SimpleNamespace
104 # Try to load distlib, with a fallback to pip's vendored version.
105 # HAVE_DISTLIB is checked below, just-in-time, so that mkvenv does not fail
106 # outside the venv or before a potential call to ensurepip in checkpip().
109 import distlib
.scripts
110 import distlib
.version
113 # Reach into pip's cookie jar. pylint and flake8 don't understand
114 # that these imports will be used via distlib.xxx.
115 from pip
._vendor
import distlib
116 import pip
._vendor
.distlib
.scripts
# noqa, pylint: disable=unused-import
117 import pip
._vendor
.distlib
.version
# noqa, pylint: disable=unused-import
121 # Try to load tomllib, with a fallback to tomli.
122 # HAVE_TOMLLIB is checked below, just-in-time, so that mkvenv does not fail
123 # outside the venv or before a potential call to ensurepip in checkpip().
129 import tomli
as tomllib
133 # Do not add any mandatory dependencies from outside the stdlib:
134 # This script *must* be usable standalone!
136 DirType
= Union
[str, bytes
, "os.PathLike[str]", "os.PathLike[bytes]"]
137 logger
= logging
.getLogger("mkvenv")
140 def inside_a_venv() -> bool:
141 """Returns True if it is executed inside of a virtual environment."""
142 return sys
.prefix
!= sys
.base_prefix
145 class Ouch(RuntimeError):
146 """An Exception class we can't confuse with a builtin."""
149 class QemuEnvBuilder(venv
.EnvBuilder
):
151 An extension of venv.EnvBuilder for building QEMU's configure-time venv.
153 The primary difference is that it emulates a "nested" virtual
154 environment when invoked from inside of an existing virtual
155 environment by including packages from the parent. Also,
156 "ensurepip" is replaced if possible with just recreating pip's
157 console_scripts inside the virtual environment.
159 Parameters for base class init:
160 - system_site_packages: bool = False
161 - clear: bool = False
162 - symlinks: bool = False
163 - upgrade: bool = False
164 - with_pip: bool = False
165 - prompt: Optional[str] = None
166 - upgrade_deps: bool = False (Since 3.9)
169 def __init__(self
, *args
: Any
, **kwargs
: Any
) -> None:
170 logger
.debug("QemuEnvBuilder.__init__(...)")
172 # For nested venv emulation:
173 self
.use_parent_packages
= False
175 # Include parent packages only if we're in a venv and
176 # system_site_packages was True.
177 self
.use_parent_packages
= kwargs
.pop(
178 "system_site_packages", False
180 # Include system_site_packages only when the parent,
181 # The venv we are currently in, also does so.
182 kwargs
["system_site_packages"] = sys
.base_prefix
in site
.PREFIXES
184 # ensurepip is slow: venv creation can be very fast for cases where
185 # we allow the use of system_site_packages. Therefore, ensurepip is
186 # replaced with our own script generation once the virtual environment
188 self
.want_pip
= kwargs
.get("with_pip", False)
191 kwargs
.get("system_site_packages", False)
192 and not need_ensurepip()
194 kwargs
["with_pip"] = False
198 super().__init
__(*args
, **kwargs
)
200 # Make the context available post-creation:
201 self
._context
: Optional
[SimpleNamespace
] = None
203 def get_parent_libpath(self
) -> Optional
[str]:
204 """Return the libpath of the parent venv, if applicable."""
205 if self
.use_parent_packages
:
206 return sysconfig
.get_path("purelib")
210 def compute_venv_libpath(context
: SimpleNamespace
) -> str:
212 Compatibility wrapper for context.lib_path for Python < 3.12
214 # Python 3.12+, not strictly necessary because it's documented
215 # to be the same as 3.10 code below:
216 if sys
.version_info
>= (3, 12):
217 return context
.lib_path
220 if "venv" in sysconfig
.get_scheme_names():
221 lib_path
= sysconfig
.get_path(
222 "purelib", scheme
="venv", vars={"base": context
.env_dir
}
224 assert lib_path
is not None
227 # For Python <= 3.9 we need to hardcode this. Fortunately the
228 # code below was the same in Python 3.6-3.10, so there is only
230 if sys
.platform
== "win32":
231 return os
.path
.join(context
.env_dir
, "Lib", "site-packages")
235 "python%d.%d" % sys
.version_info
[:2],
239 def ensure_directories(self
, env_dir
: DirType
) -> SimpleNamespace
:
240 logger
.debug("ensure_directories(env_dir=%s)", env_dir
)
241 self
._context
= super().ensure_directories(env_dir
)
244 def create(self
, env_dir
: DirType
) -> None:
245 logger
.debug("create(env_dir=%s)", env_dir
)
246 super().create(env_dir
)
247 assert self
._context
is not None
248 self
.post_post_setup(self
._context
)
250 def post_post_setup(self
, context
: SimpleNamespace
) -> None:
252 The final, final hook. Enter the venv and run commands inside of it.
254 if self
.use_parent_packages
:
255 # We're inside of a venv and we want to include the parent
257 parent_libpath
= self
.get_parent_libpath()
258 assert parent_libpath
is not None
259 logger
.debug("parent_libpath: %s", parent_libpath
)
261 our_libpath
= self
.compute_venv_libpath(context
)
262 logger
.debug("our_libpath: %s", our_libpath
)
264 pth_file
= os
.path
.join(our_libpath
, "nested.pth")
265 with
open(pth_file
, "w", encoding
="UTF-8") as file:
266 file.write(parent_libpath
+ os
.linesep
)
274 subprocess
.run(args
, check
=True)
276 def get_value(self
, field
: str) -> str:
278 Get a string value from the context namespace after a call to build.
280 For valid field names, see:
281 https://docs.python.org/3/library/venv.html#venv.EnvBuilder.ensure_directories
283 ret
= getattr(self
._context
, field
)
284 assert isinstance(ret
, str)
288 def need_ensurepip() -> bool:
290 Tests for the presence of setuptools and pip.
292 :return: `True` if we do not detect both packages.
294 # Don't try to actually import them, it's fraught with danger:
295 # https://github.com/pypa/setuptools/issues/2993
296 if find_spec("setuptools") and find_spec("pip"):
301 def check_ensurepip() -> None:
303 Check that we have ensurepip.
305 Raise a fatal exception with a helpful hint if it isn't available.
307 if not find_spec("ensurepip"):
309 "Python's ensurepip module is not found.\n"
310 "It's normally part of the Python standard library, "
311 "maybe your distribution packages it separately?\n"
312 "Either install ensurepip, or alleviate the need for it in the "
313 "first place by installing pip and setuptools for "
314 f
"'{sys.executable}'.\n"
315 "(Hint: Debian puts ensurepip in its python3-venv package.)"
319 # ensurepip uses pyexpat, which can also go missing on us:
320 if not find_spec("pyexpat"):
322 "Python's pyexpat module is not found.\n"
323 "It's normally part of the Python standard library, "
324 "maybe your distribution packages it separately?\n"
325 "Either install pyexpat, or alleviate the need for it in the "
326 "first place by installing pip and setuptools for "
327 f
"'{sys.executable}'.\n\n"
328 "(Hint: NetBSD's pkgsrc debundles this to e.g. 'py310-expat'.)"
333 def make_venv( # pylint: disable=too-many-arguments
334 env_dir
: Union
[str, Path
],
335 system_site_packages
: bool = False,
337 symlinks
: Optional
[bool] = None,
338 with_pip
: bool = True,
341 Create a venv using `QemuEnvBuilder`.
343 This is analogous to the `venv.create` module-level convenience
344 function that is part of the Python stdblib, except it uses
345 `QemuEnvBuilder` instead.
347 :param env_dir: The directory to create/install to.
348 :param system_site_packages:
349 Allow inheriting packages from the system installation.
350 :param clear: When True, fully remove any prior venv and files.
352 Whether to use symlinks to the target interpreter or not. If
353 left unspecified, it will use symlinks except on Windows to
354 match behavior with the "venv" CLI tool.
356 Whether to install "pip" binaries or not.
359 "%s: make_venv(env_dir=%s, system_site_packages=%s, "
360 "clear=%s, symlinks=%s, with_pip=%s)",
363 system_site_packages
,
370 # Default behavior of standard venv CLI
371 symlinks
= os
.name
!= "nt"
373 builder
= QemuEnvBuilder(
374 system_site_packages
=system_site_packages
,
380 style
= "non-isolated" if builder
.system_site_packages
else "isolated"
382 if builder
.use_parent_packages
:
383 nested
= f
"(with packages from '{builder.get_parent_libpath()}') "
385 f
"mkvenv: Creating {style} virtual environment"
386 f
" {nested}at '{str(env_dir)}'",
391 logger
.debug("Invoking builder.create()")
393 builder
.create(str(env_dir
))
394 except SystemExit as exc
:
395 # Some versions of the venv module raise SystemExit; *nasty*!
396 # We want the exception that prompted it. It might be a subprocess
397 # error that has output we *really* want to see.
398 logger
.debug("Intercepted SystemExit from EnvBuilder.create()")
399 raise exc
.__cause
__ or exc
.__context
__ or exc
400 logger
.debug("builder.create() finished")
401 except subprocess
.CalledProcessError
as exc
:
402 logger
.error("mkvenv subprocess failed:")
403 logger
.error("cmd: %s", exc
.cmd
)
404 logger
.error("returncode: %d", exc
.returncode
)
406 def _stringify(data
: Union
[str, bytes
]) -> str:
407 if isinstance(data
, bytes
):
413 lines
.append("========== stdout ==========")
414 lines
.append(_stringify(exc
.stdout
))
415 lines
.append("============================")
417 lines
.append("========== stderr ==========")
418 lines
.append(_stringify(exc
.stderr
))
419 lines
.append("============================")
421 logger
.error(os
.linesep
.join(lines
))
423 raise Ouch("VENV creation subprocess failed.") from exc
425 # print the python executable to stdout for configure.
426 print(builder
.get_value("env_exe"))
429 def _get_entry_points(packages
: Sequence
[str]) -> Iterator
[str]:
431 def _generator() -> Iterator
[str]:
432 for package
in packages
:
434 entry_points
: Iterator
[EntryPoint
] = \
435 iter(distribution(package
).entry_points
)
436 except PackageNotFoundError
:
439 # The EntryPoints type is only available in 3.10+,
440 # treat this as a vanilla list and filter it ourselves.
441 entry_points
= filter(
442 lambda ep
: ep
.group
== "console_scripts", entry_points
445 for entry_point
in entry_points
:
446 yield f
"{entry_point.name} = {entry_point.value}"
451 def generate_console_scripts(
452 packages
: Sequence
[str],
453 python_path
: Optional
[str] = None,
454 bin_path
: Optional
[str] = None,
457 Generate script shims for console_script entry points in @packages.
459 if python_path
is None:
460 python_path
= sys
.executable
462 bin_path
= sysconfig
.get_path("scripts")
463 assert bin_path
is not None
466 "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)",
475 maker
= distlib
.scripts
.ScriptMaker(None, bin_path
)
476 maker
.variants
= {""}
477 maker
.clobber
= False
479 for entry_point
in _get_entry_points(packages
):
480 for filename
in maker
.make(entry_point
):
481 logger
.debug("wrote console_script '%s'", filename
)
484 def pkgname_from_depspec(dep_spec
: str) -> str:
486 Parse package name out of a PEP-508 depspec.
488 See https://peps.python.org/pep-0508/#names
491 r
"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec
, re
.IGNORECASE
495 f
"dep_spec '{dep_spec}'"
496 " does not appear to contain a valid package name"
498 return match
.group(0)
501 def _path_is_prefix(prefix
: Optional
[str], path
: str) -> bool:
504 prefix
is not None and os
.path
.commonpath([prefix
, path
]) == prefix
510 def _is_system_package(dist
: Distribution
) -> bool:
511 path
= str(dist
.locate_file("."))
513 _path_is_prefix(sysconfig
.get_path("purelib"), path
)
514 or _path_is_prefix(sysconfig
.get_path("platlib"), path
)
521 wheels_dir
: Optional
[Union
[str, Path
]],
523 ) -> Tuple
[str, bool]:
525 Offer a summary to the user as to why a package failed to be installed.
527 :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5'
528 :param online: Did we allow PyPI access?
530 Optionally, a shell program name that can be used as a
531 bellwether to detect if this program is installed elsewhere on
532 the system. This is used to offer advice when a program is
533 detected for a different python version.
535 Optionally, a directory that was searched for vendored packages.
537 # pylint: disable=too-many-branches
539 # Some errors are not particularly serious
542 pkg_name
= pkgname_from_depspec(dep_spec
)
543 pkg_version
: Optional
[str] = None
545 pkg_version
= version(pkg_name
)
546 except PackageNotFoundError
:
553 f
"Python package '{pkg_name}' version '{pkg_version}' was found,"
554 " but isn't suitable."
558 f
"Python package '{pkg_name}' was not found nor installed."
563 "No suitable version found in, or failed to install from"
569 lines
.append("A suitable version could not be obtained from PyPI.")
573 "mkvenv was configured to operate offline and did not check PyPI."
576 if prog
and not pkg_version
:
577 which
= shutil
.which(prog
)
579 if sys
.base_prefix
in site
.PREFIXES
:
580 pypath
= Path(sys
.executable
).resolve()
582 f
"'{prog}' was detected on your system at '{which}', "
583 f
"but the Python package '{pkg_name}' was not found by "
584 f
"this Python interpreter ('{pypath}'). "
585 f
"Typically this means that '{prog}' has been installed "
586 "against a different Python interpreter on your system."
590 f
"'{prog}' was detected on your system at '{which}', "
591 "but the build is using an isolated virtual environment."
595 lines
= [f
" • {line}" for line
in lines
]
597 lines
.insert(0, f
"Could not provide build dependency '{dep_spec}':")
599 lines
.insert(0, f
"'{dep_spec}' not found:")
600 return os
.linesep
.join(lines
), bad
605 online
: bool = False,
606 wheels_dir
: Optional
[Union
[str, Path
]] = None,
609 Use pip to install a package or package(s) as specified in @args.
612 os
.environ
.get("DEBUG")
613 or os
.environ
.get("GITLAB_CI")
614 or os
.environ
.get("V")
622 "--disable-pip-version-check",
623 "-v" if loud
else "-q",
626 full_args
+= ["--no-index"]
628 full_args
+= ["--find-links", f
"file://{str(wheels_dir)}"]
629 full_args
+= list(args
)
636 def _make_version_constraint(info
: Dict
[str, str], install
: bool) -> str:
638 Construct the version constraint part of a PEP 508 dependency
639 specification (for example '>=0.61.5') from the accepted and
640 installed keys of the provided dictionary.
642 :param info: A dictionary corresponding to a TOML key-value list.
643 :param install: True generates install constraints, False generates
646 if install
and "installed" in info
:
647 return "==" + info
["installed"]
649 dep_spec
= info
.get("accepted", "")
650 dep_spec
= dep_spec
.strip()
651 # Double check that they didn't just use a version number
652 if dep_spec
and dep_spec
[0] not in "!~><=(":
654 "invalid dependency specifier " + dep_spec
+ " in dependency file"
661 group
: Dict
[str, Dict
[str, str]],
662 online
: bool = False,
663 wheels_dir
: Optional
[Union
[str, Path
]] = None,
664 ) -> Optional
[Tuple
[str, bool]]:
666 Use pip to ensure we have the packages specified in @group.
668 If the packages are already installed, do nothing. If online and
669 wheels_dir are both provided, prefer packages found in wheels_dir
670 first before connecting to PyPI.
672 :param group: A dictionary of dictionaries, corresponding to a
673 section in a pythondeps.toml file.
674 :param online: If True, fall back to PyPI.
675 :param wheels_dir: If specified, search this path for packages.
680 for name
, info
in group
.items():
681 constraint
= _make_version_constraint(info
, False)
682 matcher
= distlib
.version
.LegacyMatcher(name
+ constraint
)
683 print(f
"mkvenv: checking for {matcher}", file=sys
.stderr
)
685 dist
: Optional
[Distribution
] = None
687 dist
= distribution(matcher
.name
)
688 except PackageNotFoundError
:
693 # Always pass installed package to pip, so that they can be
694 # updated if the requested version changes
695 or not _is_system_package(dist
)
696 or not matcher
.match(distlib
.version
.LegacyVersion(dist
.version
))
698 absent
.append(name
+ _make_version_constraint(info
, True))
700 canary
= info
.get("canary", None)
702 logger
.info("found %s %s", name
, dist
.version
)
706 generate_console_scripts(present
)
709 if online
or wheels_dir
:
710 # Some packages are missing or aren't a suitable version,
711 # install a suitable (possibly vendored) package.
712 print(f
"mkvenv: installing {', '.join(absent)}", file=sys
.stderr
)
714 pip_install(args
=absent
, online
=online
, wheels_dir
=wheels_dir
)
716 except subprocess
.CalledProcessError
:
730 dep_specs
: Sequence
[str],
731 online
: bool = False,
732 wheels_dir
: Optional
[Union
[str, Path
]] = None,
733 prog
: Optional
[str] = None,
736 Use pip to ensure we have the package specified by @dep_specs.
738 If the package is already installed, do nothing. If online and
739 wheels_dir are both provided, prefer packages found in wheels_dir
740 first before connecting to PyPI.
743 PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
744 :param online: If True, fall back to PyPI.
745 :param wheels_dir: If specified, search this path for packages.
747 If specified, use this program name for error diagnostics that will
748 be presented to the user. e.g., 'sphinx-build' can be used as a
749 bellwether for the presence of 'sphinx'.
753 raise Ouch("a usable distlib could not be found, please install it")
755 # Convert the depspecs to a dictionary, as if they came
756 # from a section in a pythondeps.toml file
757 group
: Dict
[str, Dict
[str, str]] = {}
758 for spec
in dep_specs
:
759 name
= distlib
.version
.LegacyMatcher(spec
).name
764 ver
= spec
[pos
:].strip()
766 group
[name
]["accepted"] = ver
769 group
[name
]["canary"] = prog
772 result
= _do_ensure(group
, online
, wheels_dir
)
774 # Well, that's not good.
776 raise Ouch(result
[0])
777 raise SystemExit(f
"\n{result[0]}\n\n")
780 def _parse_groups(file: str) -> Dict
[str, Dict
[str, Any
]]:
782 if sys
.version_info
< (3, 11):
783 raise Ouch("found no usable tomli, please install it")
786 "Python >=3.11 does not have tomllib... what have you done!?"
789 # Use loads() to support both tomli v1.2.x (Ubuntu 22.04,
790 # Debian bullseye-backports) and v2.0.x
791 with
open(file, "r", encoding
="ascii") as depfile
:
792 contents
= depfile
.read()
793 return tomllib
.loads(contents
) # type: ignore
798 groups
: Sequence
[str],
799 online
: bool = False,
800 wheels_dir
: Optional
[Union
[str, Path
]] = None,
803 Use pip to ensure we have the package specified by @dep_specs.
805 If the package is already installed, do nothing. If online and
806 wheels_dir are both provided, prefer packages found in wheels_dir
807 first before connecting to PyPI.
810 PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
811 :param online: If True, fall back to PyPI.
812 :param wheels_dir: If specified, search this path for packages.
816 raise Ouch("found no usable distlib, please install it")
818 parsed_deps
= _parse_groups(file)
820 to_install
: Dict
[str, Dict
[str, str]] = {}
823 to_install
.update(parsed_deps
[group
])
824 except KeyError as exc
:
825 raise Ouch(f
"group {group} not defined") from exc
827 result
= _do_ensure(to_install
, online
, wheels_dir
)
829 # Well, that's not good.
831 raise Ouch(result
[0])
832 raise SystemExit(f
"\n{result[0]}\n\n")
835 def post_venv_setup() -> None:
837 This is intended to be run *inside the venv* after it is created.
839 logger
.debug("post_venv_setup()")
840 # Generate a 'pip' script so the venv is usable in a normal
841 # way from the CLI. This only happens when we inherited pip from a
842 # parent/system-site and haven't run ensurepip in some way.
843 generate_console_scripts(["pip"])
846 def _add_create_subcommand(subparsers
: Any
) -> None:
847 subparser
= subparsers
.add_parser("create", help="create a venv")
848 subparser
.add_argument(
852 help="Target directory to install virtual environment into.",
856 def _add_post_init_subcommand(subparsers
: Any
) -> None:
857 subparsers
.add_parser("post_init", help="post-venv initialization")
860 def _add_ensuregroup_subcommand(subparsers
: Any
) -> None:
861 subparser
= subparsers
.add_parser(
863 help="Ensure that the specified package group is installed.",
865 subparser
.add_argument(
868 help="Install packages from PyPI, if necessary.",
870 subparser
.add_argument(
874 help="Path to vendored packages where we may install from.",
876 subparser
.add_argument(
880 help=("Path to a TOML file describing package groups"),
882 subparser
.add_argument(
886 help="One or more package group names",
891 def _add_ensure_subcommand(subparsers
: Any
) -> None:
892 subparser
= subparsers
.add_parser(
893 "ensure", help="Ensure that the specified package is installed."
895 subparser
.add_argument(
898 help="Install packages from PyPI, if necessary.",
900 subparser
.add_argument(
904 help="Path to vendored packages where we may install from.",
906 subparser
.add_argument(
911 "Name of a shell utility to use for "
912 "diagnostics if this command fails."
915 subparser
.add_argument(
919 help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'",
925 """CLI interface to make_qemu_venv. See module docstring."""
926 if os
.environ
.get("DEBUG") or os
.environ
.get("GITLAB_CI"):
928 logging
.basicConfig(level
=logging
.DEBUG
)
930 if os
.environ
.get("V"):
931 logging
.basicConfig(level
=logging
.INFO
)
933 parser
= argparse
.ArgumentParser(
935 description
="QEMU pyvenv bootstrapping utility",
937 subparsers
= parser
.add_subparsers(
945 _add_create_subcommand(subparsers
)
946 _add_post_init_subcommand(subparsers
)
947 _add_ensure_subcommand(subparsers
)
948 _add_ensuregroup_subcommand(subparsers
)
950 args
= parser
.parse_args()
952 if args
.command
== "create":
955 system_site_packages
=True,
958 if args
.command
== "post_init":
960 if args
.command
== "ensure":
962 dep_specs
=args
.dep_specs
,
967 if args
.command
== "ensuregroup":
974 logger
.debug("mkvenv.py %s: exiting", args
.command
)
976 print("\n*** Ouch! ***\n", file=sys
.stderr
)
977 print(str(exc
), "\n\n", file=sys
.stderr
)
981 except: # pylint: disable=bare-except
982 logger
.exception("mkvenv did not complete successfully:")
987 if __name__
== "__main__":