mkvenv.py 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168
  1. """
  2. mkvenv - QEMU pyvenv bootstrapping utility
  3. usage: mkvenv [-h] command ...
  4. QEMU pyvenv bootstrapping utility
  5. options:
  6. -h, --help show this help message and exit
  7. Commands:
  8. command Description
  9. create create a venv
  10. post_init
  11. post-venv initialization
  12. ensure Ensure that the specified package is installed.
  13. ensuregroup
  14. Ensure that the specified package group is installed.
  15. --------------------------------------------------
  16. usage: mkvenv create [-h] target
  17. positional arguments:
  18. target Target directory to install virtual environment into.
  19. options:
  20. -h, --help show this help message and exit
  21. --------------------------------------------------
  22. usage: mkvenv post_init [-h]
  23. options:
  24. -h, --help show this help message and exit
  25. --------------------------------------------------
  26. usage: mkvenv ensure [-h] [--online] [--dir DIR] dep_spec...
  27. positional arguments:
  28. dep_spec PEP 508 Dependency specification, e.g. 'meson>=0.61.5'
  29. options:
  30. -h, --help show this help message and exit
  31. --online Install packages from PyPI, if necessary.
  32. --dir DIR Path to vendored packages where we may install from.
  33. --------------------------------------------------
  34. usage: mkvenv ensuregroup [-h] [--online] [--dir DIR] file group...
  35. positional arguments:
  36. file pointer to a TOML file
  37. group section name in the TOML file
  38. options:
  39. -h, --help show this help message and exit
  40. --online Install packages from PyPI, if necessary.
  41. --dir DIR Path to vendored packages where we may install from.
  42. """
  43. # The duplication between importlib and pkg_resources does not help
  44. # pylint: disable=too-many-lines
  45. # Copyright (C) 2022-2023 Red Hat, Inc.
  46. #
  47. # Authors:
  48. # John Snow <jsnow@redhat.com>
  49. # Paolo Bonzini <pbonzini@redhat.com>
  50. #
  51. # This work is licensed under the terms of the GNU GPL, version 2 or
  52. # later. See the COPYING file in the top-level directory.
  53. import argparse
  54. from importlib.util import find_spec
  55. import logging
  56. import os
  57. from pathlib import Path
  58. import re
  59. import shutil
  60. import site
  61. import subprocess
  62. import sys
  63. import sysconfig
  64. from types import SimpleNamespace
  65. from typing import (
  66. Any,
  67. Dict,
  68. Iterator,
  69. Optional,
  70. Sequence,
  71. Tuple,
  72. Union,
  73. )
  74. import venv
  75. # Try to load distlib, with a fallback to pip's vendored version.
  76. # HAVE_DISTLIB is checked below, just-in-time, so that mkvenv does not fail
  77. # outside the venv or before a potential call to ensurepip in checkpip().
  78. HAVE_DISTLIB = True
  79. try:
  80. import distlib.scripts
  81. import distlib.version
  82. except ImportError:
  83. try:
  84. # Reach into pip's cookie jar. pylint and flake8 don't understand
  85. # that these imports will be used via distlib.xxx.
  86. from pip._vendor import distlib
  87. import pip._vendor.distlib.scripts # noqa, pylint: disable=unused-import
  88. import pip._vendor.distlib.version # noqa, pylint: disable=unused-import
  89. except ImportError:
  90. HAVE_DISTLIB = False
  91. # Try to load tomllib, with a fallback to tomli.
  92. # HAVE_TOMLLIB is checked below, just-in-time, so that mkvenv does not fail
  93. # outside the venv or before a potential call to ensurepip in checkpip().
  94. HAVE_TOMLLIB = True
  95. try:
  96. import tomllib
  97. except ImportError:
  98. try:
  99. import tomli as tomllib
  100. except ImportError:
  101. HAVE_TOMLLIB = False
  102. # Do not add any mandatory dependencies from outside the stdlib:
  103. # This script *must* be usable standalone!
  104. DirType = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"]
  105. logger = logging.getLogger("mkvenv")
  106. def inside_a_venv() -> bool:
  107. """Returns True if it is executed inside of a virtual environment."""
  108. return sys.prefix != sys.base_prefix
  109. class Ouch(RuntimeError):
  110. """An Exception class we can't confuse with a builtin."""
  111. class QemuEnvBuilder(venv.EnvBuilder):
  112. """
  113. An extension of venv.EnvBuilder for building QEMU's configure-time venv.
  114. The primary difference is that it emulates a "nested" virtual
  115. environment when invoked from inside of an existing virtual
  116. environment by including packages from the parent. Also,
  117. "ensurepip" is replaced if possible with just recreating pip's
  118. console_scripts inside the virtual environment.
  119. Parameters for base class init:
  120. - system_site_packages: bool = False
  121. - clear: bool = False
  122. - symlinks: bool = False
  123. - upgrade: bool = False
  124. - with_pip: bool = False
  125. - prompt: Optional[str] = None
  126. - upgrade_deps: bool = False (Since 3.9)
  127. """
  128. def __init__(self, *args: Any, **kwargs: Any) -> None:
  129. logger.debug("QemuEnvBuilder.__init__(...)")
  130. # For nested venv emulation:
  131. self.use_parent_packages = False
  132. if inside_a_venv():
  133. # Include parent packages only if we're in a venv and
  134. # system_site_packages was True.
  135. self.use_parent_packages = kwargs.pop(
  136. "system_site_packages", False
  137. )
  138. # Include system_site_packages only when the parent,
  139. # The venv we are currently in, also does so.
  140. kwargs["system_site_packages"] = sys.base_prefix in site.PREFIXES
  141. # ensurepip is slow: venv creation can be very fast for cases where
  142. # we allow the use of system_site_packages. Therefore, ensurepip is
  143. # replaced with our own script generation once the virtual environment
  144. # is setup.
  145. self.want_pip = kwargs.get("with_pip", False)
  146. if self.want_pip:
  147. if (
  148. kwargs.get("system_site_packages", False)
  149. and not need_ensurepip()
  150. ):
  151. kwargs["with_pip"] = False
  152. else:
  153. check_ensurepip(suggest_remedy=True)
  154. super().__init__(*args, **kwargs)
  155. # Make the context available post-creation:
  156. self._context: Optional[SimpleNamespace] = None
  157. def get_parent_libpath(self) -> Optional[str]:
  158. """Return the libpath of the parent venv, if applicable."""
  159. if self.use_parent_packages:
  160. return sysconfig.get_path("purelib")
  161. return None
  162. @staticmethod
  163. def compute_venv_libpath(context: SimpleNamespace) -> str:
  164. """
  165. Compatibility wrapper for context.lib_path for Python < 3.12
  166. """
  167. # Python 3.12+, not strictly necessary because it's documented
  168. # to be the same as 3.10 code below:
  169. if sys.version_info >= (3, 12):
  170. return context.lib_path
  171. # Python 3.10+
  172. if "venv" in sysconfig.get_scheme_names():
  173. lib_path = sysconfig.get_path(
  174. "purelib", scheme="venv", vars={"base": context.env_dir}
  175. )
  176. assert lib_path is not None
  177. return lib_path
  178. # For Python <= 3.9 we need to hardcode this. Fortunately the
  179. # code below was the same in Python 3.6-3.10, so there is only
  180. # one case.
  181. if sys.platform == "win32":
  182. return os.path.join(context.env_dir, "Lib", "site-packages")
  183. return os.path.join(
  184. context.env_dir,
  185. "lib",
  186. "python%d.%d" % sys.version_info[:2],
  187. "site-packages",
  188. )
  189. def ensure_directories(self, env_dir: DirType) -> SimpleNamespace:
  190. logger.debug("ensure_directories(env_dir=%s)", env_dir)
  191. self._context = super().ensure_directories(env_dir)
  192. return self._context
  193. def create(self, env_dir: DirType) -> None:
  194. logger.debug("create(env_dir=%s)", env_dir)
  195. super().create(env_dir)
  196. assert self._context is not None
  197. self.post_post_setup(self._context)
  198. def post_post_setup(self, context: SimpleNamespace) -> None:
  199. """
  200. The final, final hook. Enter the venv and run commands inside of it.
  201. """
  202. if self.use_parent_packages:
  203. # We're inside of a venv and we want to include the parent
  204. # venv's packages.
  205. parent_libpath = self.get_parent_libpath()
  206. assert parent_libpath is not None
  207. logger.debug("parent_libpath: %s", parent_libpath)
  208. our_libpath = self.compute_venv_libpath(context)
  209. logger.debug("our_libpath: %s", our_libpath)
  210. pth_file = os.path.join(our_libpath, "nested.pth")
  211. with open(pth_file, "w", encoding="UTF-8") as file:
  212. file.write(parent_libpath + os.linesep)
  213. if self.want_pip:
  214. args = [
  215. context.env_exe,
  216. __file__,
  217. "post_init",
  218. ]
  219. subprocess.run(args, check=True)
  220. def get_value(self, field: str) -> str:
  221. """
  222. Get a string value from the context namespace after a call to build.
  223. For valid field names, see:
  224. https://docs.python.org/3/library/venv.html#venv.EnvBuilder.ensure_directories
  225. """
  226. ret = getattr(self._context, field)
  227. assert isinstance(ret, str)
  228. return ret
  229. def need_ensurepip() -> bool:
  230. """
  231. Tests for the presence of setuptools and pip.
  232. :return: `True` if we do not detect both packages.
  233. """
  234. # Don't try to actually import them, it's fraught with danger:
  235. # https://github.com/pypa/setuptools/issues/2993
  236. if find_spec("setuptools") and find_spec("pip"):
  237. return False
  238. return True
  239. def check_ensurepip(prefix: str = "", suggest_remedy: bool = False) -> None:
  240. """
  241. Check that we have ensurepip.
  242. Raise a fatal exception with a helpful hint if it isn't available.
  243. """
  244. if not find_spec("ensurepip"):
  245. msg = (
  246. "Python's ensurepip module is not found.\n"
  247. "It's normally part of the Python standard library, "
  248. "maybe your distribution packages it separately?\n"
  249. "(Debian puts ensurepip in its python3-venv package.)\n"
  250. )
  251. if suggest_remedy:
  252. msg += (
  253. "Either install ensurepip, or alleviate the need for it in the"
  254. " first place by installing pip and setuptools for "
  255. f"'{sys.executable}'.\n"
  256. )
  257. raise Ouch(prefix + msg)
  258. # ensurepip uses pyexpat, which can also go missing on us:
  259. if not find_spec("pyexpat"):
  260. msg = (
  261. "Python's pyexpat module is not found.\n"
  262. "It's normally part of the Python standard library, "
  263. "maybe your distribution packages it separately?\n"
  264. "(NetBSD's pkgsrc debundles this to e.g. 'py310-expat'.)\n"
  265. )
  266. if suggest_remedy:
  267. msg += (
  268. "Either install pyexpat, or alleviate the need for it in the "
  269. "first place by installing pip and setuptools for "
  270. f"'{sys.executable}'.\n"
  271. )
  272. raise Ouch(prefix + msg)
  273. def make_venv( # pylint: disable=too-many-arguments
  274. env_dir: Union[str, Path],
  275. system_site_packages: bool = False,
  276. clear: bool = True,
  277. symlinks: Optional[bool] = None,
  278. with_pip: bool = True,
  279. ) -> None:
  280. """
  281. Create a venv using `QemuEnvBuilder`.
  282. This is analogous to the `venv.create` module-level convenience
  283. function that is part of the Python stdblib, except it uses
  284. `QemuEnvBuilder` instead.
  285. :param env_dir: The directory to create/install to.
  286. :param system_site_packages:
  287. Allow inheriting packages from the system installation.
  288. :param clear: When True, fully remove any prior venv and files.
  289. :param symlinks:
  290. Whether to use symlinks to the target interpreter or not. If
  291. left unspecified, it will use symlinks except on Windows to
  292. match behavior with the "venv" CLI tool.
  293. :param with_pip:
  294. Whether to install "pip" binaries or not.
  295. """
  296. logger.debug(
  297. "%s: make_venv(env_dir=%s, system_site_packages=%s, "
  298. "clear=%s, symlinks=%s, with_pip=%s)",
  299. __file__,
  300. str(env_dir),
  301. system_site_packages,
  302. clear,
  303. symlinks,
  304. with_pip,
  305. )
  306. if symlinks is None:
  307. # Default behavior of standard venv CLI
  308. symlinks = os.name != "nt"
  309. builder = QemuEnvBuilder(
  310. system_site_packages=system_site_packages,
  311. clear=clear,
  312. symlinks=symlinks,
  313. with_pip=with_pip,
  314. )
  315. style = "non-isolated" if builder.system_site_packages else "isolated"
  316. nested = ""
  317. if builder.use_parent_packages:
  318. nested = f"(with packages from '{builder.get_parent_libpath()}') "
  319. print(
  320. f"mkvenv: Creating {style} virtual environment"
  321. f" {nested}at '{str(env_dir)}'",
  322. file=sys.stderr,
  323. )
  324. try:
  325. logger.debug("Invoking builder.create()")
  326. try:
  327. builder.create(str(env_dir))
  328. except SystemExit as exc:
  329. # Some versions of the venv module raise SystemExit; *nasty*!
  330. # We want the exception that prompted it. It might be a subprocess
  331. # error that has output we *really* want to see.
  332. logger.debug("Intercepted SystemExit from EnvBuilder.create()")
  333. raise exc.__cause__ or exc.__context__ or exc
  334. logger.debug("builder.create() finished")
  335. except subprocess.CalledProcessError as exc:
  336. logger.error("mkvenv subprocess failed:")
  337. logger.error("cmd: %s", exc.cmd)
  338. logger.error("returncode: %d", exc.returncode)
  339. def _stringify(data: Union[str, bytes]) -> str:
  340. if isinstance(data, bytes):
  341. return data.decode()
  342. return data
  343. lines = []
  344. if exc.stdout:
  345. lines.append("========== stdout ==========")
  346. lines.append(_stringify(exc.stdout))
  347. lines.append("============================")
  348. if exc.stderr:
  349. lines.append("========== stderr ==========")
  350. lines.append(_stringify(exc.stderr))
  351. lines.append("============================")
  352. if lines:
  353. logger.error(os.linesep.join(lines))
  354. raise Ouch("VENV creation subprocess failed.") from exc
  355. # print the python executable to stdout for configure.
  356. print(builder.get_value("env_exe"))
  357. def _gen_importlib(packages: Sequence[str]) -> Iterator[str]:
  358. # pylint: disable=import-outside-toplevel
  359. # pylint: disable=no-name-in-module
  360. # pylint: disable=import-error
  361. try:
  362. # First preference: Python 3.8+ stdlib
  363. from importlib.metadata import ( # type: ignore
  364. PackageNotFoundError,
  365. distribution,
  366. )
  367. except ImportError as exc:
  368. logger.debug("%s", str(exc))
  369. # Second preference: Commonly available PyPI backport
  370. from importlib_metadata import ( # type: ignore
  371. PackageNotFoundError,
  372. distribution,
  373. )
  374. def _generator() -> Iterator[str]:
  375. for package in packages:
  376. try:
  377. entry_points = distribution(package).entry_points
  378. except PackageNotFoundError:
  379. continue
  380. # The EntryPoints type is only available in 3.10+,
  381. # treat this as a vanilla list and filter it ourselves.
  382. entry_points = filter(
  383. lambda ep: ep.group == "console_scripts", entry_points
  384. )
  385. for entry_point in entry_points:
  386. yield f"{entry_point.name} = {entry_point.value}"
  387. return _generator()
  388. def _gen_pkg_resources(packages: Sequence[str]) -> Iterator[str]:
  389. # pylint: disable=import-outside-toplevel
  390. # Bundled with setuptools; has a good chance of being available.
  391. import pkg_resources
  392. def _generator() -> Iterator[str]:
  393. for package in packages:
  394. try:
  395. eps = pkg_resources.get_entry_map(package, "console_scripts")
  396. except pkg_resources.DistributionNotFound:
  397. continue
  398. for entry_point in eps.values():
  399. yield str(entry_point)
  400. return _generator()
  401. def generate_console_scripts(
  402. packages: Sequence[str],
  403. python_path: Optional[str] = None,
  404. bin_path: Optional[str] = None,
  405. ) -> None:
  406. """
  407. Generate script shims for console_script entry points in @packages.
  408. """
  409. if python_path is None:
  410. python_path = sys.executable
  411. if bin_path is None:
  412. bin_path = sysconfig.get_path("scripts")
  413. assert bin_path is not None
  414. logger.debug(
  415. "generate_console_scripts(packages=%s, python_path=%s, bin_path=%s)",
  416. packages,
  417. python_path,
  418. bin_path,
  419. )
  420. if not packages:
  421. return
  422. def _get_entry_points() -> Iterator[str]:
  423. """Python 3.7 compatibility shim for iterating entry points."""
  424. # Python 3.8+, or Python 3.7 with importlib_metadata installed.
  425. try:
  426. return _gen_importlib(packages)
  427. except ImportError as exc:
  428. logger.debug("%s", str(exc))
  429. # Python 3.7 with setuptools installed.
  430. try:
  431. return _gen_pkg_resources(packages)
  432. except ImportError as exc:
  433. logger.debug("%s", str(exc))
  434. raise Ouch(
  435. "Neither importlib.metadata nor pkg_resources found, "
  436. "can't generate console script shims.\n"
  437. "Use Python 3.8+, or install importlib-metadata or setuptools."
  438. ) from exc
  439. maker = distlib.scripts.ScriptMaker(None, bin_path)
  440. maker.variants = {""}
  441. maker.clobber = False
  442. for entry_point in _get_entry_points():
  443. for filename in maker.make(entry_point):
  444. logger.debug("wrote console_script '%s'", filename)
  445. def checkpip() -> bool:
  446. """
  447. Debian10 has a pip that's broken when used inside of a virtual environment.
  448. We try to detect and correct that case here.
  449. """
  450. try:
  451. # pylint: disable=import-outside-toplevel,unused-import,import-error
  452. # pylint: disable=redefined-outer-name
  453. import pip._internal # type: ignore # noqa: F401
  454. logger.debug("pip appears to be working correctly.")
  455. return False
  456. except ModuleNotFoundError as exc:
  457. if exc.name == "pip._internal":
  458. # Uh, fair enough. They did say "internal".
  459. # Let's just assume it's fine.
  460. return False
  461. logger.warning("pip appears to be malfunctioning: %s", str(exc))
  462. check_ensurepip("pip appears to be non-functional, and ")
  463. logger.debug("Attempting to repair pip ...")
  464. subprocess.run(
  465. (sys.executable, "-m", "ensurepip"),
  466. stdout=subprocess.DEVNULL,
  467. check=True,
  468. )
  469. logger.debug("Pip is now (hopefully) repaired!")
  470. return True
  471. def pkgname_from_depspec(dep_spec: str) -> str:
  472. """
  473. Parse package name out of a PEP-508 depspec.
  474. See https://peps.python.org/pep-0508/#names
  475. """
  476. match = re.match(
  477. r"^([A-Z0-9]([A-Z0-9._-]*[A-Z0-9])?)", dep_spec, re.IGNORECASE
  478. )
  479. if not match:
  480. raise ValueError(
  481. f"dep_spec '{dep_spec}'"
  482. " does not appear to contain a valid package name"
  483. )
  484. return match.group(0)
  485. def _get_path_importlib(package: str) -> Optional[str]:
  486. # pylint: disable=import-outside-toplevel
  487. # pylint: disable=no-name-in-module
  488. # pylint: disable=import-error
  489. try:
  490. # First preference: Python 3.8+ stdlib
  491. from importlib.metadata import ( # type: ignore
  492. PackageNotFoundError,
  493. distribution,
  494. )
  495. except ImportError as exc:
  496. logger.debug("%s", str(exc))
  497. # Second preference: Commonly available PyPI backport
  498. from importlib_metadata import ( # type: ignore
  499. PackageNotFoundError,
  500. distribution,
  501. )
  502. try:
  503. return str(distribution(package).locate_file("."))
  504. except PackageNotFoundError:
  505. return None
  506. def _get_path_pkg_resources(package: str) -> Optional[str]:
  507. # pylint: disable=import-outside-toplevel
  508. # Bundled with setuptools; has a good chance of being available.
  509. import pkg_resources
  510. try:
  511. return str(pkg_resources.get_distribution(package).location)
  512. except pkg_resources.DistributionNotFound:
  513. return None
  514. def _get_path(package: str) -> Optional[str]:
  515. try:
  516. return _get_path_importlib(package)
  517. except ImportError as exc:
  518. logger.debug("%s", str(exc))
  519. try:
  520. return _get_path_pkg_resources(package)
  521. except ImportError as exc:
  522. logger.debug("%s", str(exc))
  523. raise Ouch(
  524. "Neither importlib.metadata nor pkg_resources found. "
  525. "Use Python 3.8+, or install importlib-metadata or setuptools."
  526. ) from exc
  527. def _path_is_prefix(prefix: Optional[str], path: str) -> bool:
  528. try:
  529. return (
  530. prefix is not None and os.path.commonpath([prefix, path]) == prefix
  531. )
  532. except ValueError:
  533. return False
  534. def _is_system_package(package: str) -> bool:
  535. path = _get_path(package)
  536. return path is not None and not (
  537. _path_is_prefix(sysconfig.get_path("purelib"), path)
  538. or _path_is_prefix(sysconfig.get_path("platlib"), path)
  539. )
  540. def _get_version_importlib(package: str) -> Optional[str]:
  541. # pylint: disable=import-outside-toplevel
  542. # pylint: disable=no-name-in-module
  543. # pylint: disable=import-error
  544. try:
  545. # First preference: Python 3.8+ stdlib
  546. from importlib.metadata import ( # type: ignore
  547. PackageNotFoundError,
  548. distribution,
  549. )
  550. except ImportError as exc:
  551. logger.debug("%s", str(exc))
  552. # Second preference: Commonly available PyPI backport
  553. from importlib_metadata import ( # type: ignore
  554. PackageNotFoundError,
  555. distribution,
  556. )
  557. try:
  558. return str(distribution(package).version)
  559. except PackageNotFoundError:
  560. return None
  561. def _get_version_pkg_resources(package: str) -> Optional[str]:
  562. # pylint: disable=import-outside-toplevel
  563. # Bundled with setuptools; has a good chance of being available.
  564. import pkg_resources
  565. try:
  566. return str(pkg_resources.get_distribution(package).version)
  567. except pkg_resources.DistributionNotFound:
  568. return None
  569. def _get_version(package: str) -> Optional[str]:
  570. try:
  571. return _get_version_importlib(package)
  572. except ImportError as exc:
  573. logger.debug("%s", str(exc))
  574. try:
  575. return _get_version_pkg_resources(package)
  576. except ImportError as exc:
  577. logger.debug("%s", str(exc))
  578. raise Ouch(
  579. "Neither importlib.metadata nor pkg_resources found. "
  580. "Use Python 3.8+, or install importlib-metadata or setuptools."
  581. ) from exc
  582. def diagnose(
  583. dep_spec: str,
  584. online: bool,
  585. wheels_dir: Optional[Union[str, Path]],
  586. prog: Optional[str],
  587. ) -> Tuple[str, bool]:
  588. """
  589. Offer a summary to the user as to why a package failed to be installed.
  590. :param dep_spec: The package we tried to ensure, e.g. 'meson>=0.61.5'
  591. :param online: Did we allow PyPI access?
  592. :param prog:
  593. Optionally, a shell program name that can be used as a
  594. bellwether to detect if this program is installed elsewhere on
  595. the system. This is used to offer advice when a program is
  596. detected for a different python version.
  597. :param wheels_dir:
  598. Optionally, a directory that was searched for vendored packages.
  599. """
  600. # pylint: disable=too-many-branches
  601. # Some errors are not particularly serious
  602. bad = False
  603. pkg_name = pkgname_from_depspec(dep_spec)
  604. pkg_version = _get_version(pkg_name)
  605. lines = []
  606. if pkg_version:
  607. lines.append(
  608. f"Python package '{pkg_name}' version '{pkg_version}' was found,"
  609. " but isn't suitable."
  610. )
  611. else:
  612. lines.append(
  613. f"Python package '{pkg_name}' was not found nor installed."
  614. )
  615. if wheels_dir:
  616. lines.append(
  617. "No suitable version found in, or failed to install from"
  618. f" '{wheels_dir}'."
  619. )
  620. bad = True
  621. if online:
  622. lines.append("A suitable version could not be obtained from PyPI.")
  623. bad = True
  624. else:
  625. lines.append(
  626. "mkvenv was configured to operate offline and did not check PyPI."
  627. )
  628. if prog and not pkg_version:
  629. which = shutil.which(prog)
  630. if which:
  631. if sys.base_prefix in site.PREFIXES:
  632. pypath = Path(sys.executable).resolve()
  633. lines.append(
  634. f"'{prog}' was detected on your system at '{which}', "
  635. f"but the Python package '{pkg_name}' was not found by "
  636. f"this Python interpreter ('{pypath}'). "
  637. f"Typically this means that '{prog}' has been installed "
  638. "against a different Python interpreter on your system."
  639. )
  640. else:
  641. lines.append(
  642. f"'{prog}' was detected on your system at '{which}', "
  643. "but the build is using an isolated virtual environment."
  644. )
  645. bad = True
  646. lines = [f" • {line}" for line in lines]
  647. if bad:
  648. lines.insert(0, f"Could not provide build dependency '{dep_spec}':")
  649. else:
  650. lines.insert(0, f"'{dep_spec}' not found:")
  651. return os.linesep.join(lines), bad
  652. def pip_install(
  653. args: Sequence[str],
  654. online: bool = False,
  655. wheels_dir: Optional[Union[str, Path]] = None,
  656. ) -> None:
  657. """
  658. Use pip to install a package or package(s) as specified in @args.
  659. """
  660. loud = bool(
  661. os.environ.get("DEBUG")
  662. or os.environ.get("GITLAB_CI")
  663. or os.environ.get("V")
  664. )
  665. full_args = [
  666. sys.executable,
  667. "-m",
  668. "pip",
  669. "install",
  670. "--disable-pip-version-check",
  671. "-v" if loud else "-q",
  672. ]
  673. if not online:
  674. full_args += ["--no-index"]
  675. if wheels_dir:
  676. full_args += ["--find-links", f"file://{str(wheels_dir)}"]
  677. full_args += list(args)
  678. subprocess.run(
  679. full_args,
  680. check=True,
  681. )
  682. def _make_version_constraint(info: Dict[str, str], install: bool) -> str:
  683. """
  684. Construct the version constraint part of a PEP 508 dependency
  685. specification (for example '>=0.61.5') from the accepted and
  686. installed keys of the provided dictionary.
  687. :param info: A dictionary corresponding to a TOML key-value list.
  688. :param install: True generates install constraints, False generates
  689. presence constraints
  690. """
  691. if install and "installed" in info:
  692. return "==" + info["installed"]
  693. dep_spec = info.get("accepted", "")
  694. dep_spec = dep_spec.strip()
  695. # Double check that they didn't just use a version number
  696. if dep_spec and dep_spec[0] not in "!~><=(":
  697. raise Ouch(
  698. "invalid dependency specifier " + dep_spec + " in dependency file"
  699. )
  700. return dep_spec
  701. def _do_ensure(
  702. group: Dict[str, Dict[str, str]],
  703. online: bool = False,
  704. wheels_dir: Optional[Union[str, Path]] = None,
  705. ) -> Optional[Tuple[str, bool]]:
  706. """
  707. Use pip to ensure we have the packages specified in @group.
  708. If the packages are already installed, do nothing. If online and
  709. wheels_dir are both provided, prefer packages found in wheels_dir
  710. first before connecting to PyPI.
  711. :param group: A dictionary of dictionaries, corresponding to a
  712. section in a pythondeps.toml file.
  713. :param online: If True, fall back to PyPI.
  714. :param wheels_dir: If specified, search this path for packages.
  715. """
  716. absent = []
  717. present = []
  718. canary = None
  719. for name, info in group.items():
  720. constraint = _make_version_constraint(info, False)
  721. matcher = distlib.version.LegacyMatcher(name + constraint)
  722. print(f"mkvenv: checking for {matcher}", file=sys.stderr)
  723. ver = _get_version(name)
  724. if (
  725. ver is None
  726. # Always pass installed package to pip, so that they can be
  727. # updated if the requested version changes
  728. or not _is_system_package(name)
  729. or not matcher.match(distlib.version.LegacyVersion(ver))
  730. ):
  731. absent.append(name + _make_version_constraint(info, True))
  732. if len(absent) == 1:
  733. canary = info.get("canary", None)
  734. else:
  735. logger.info("found %s %s", name, ver)
  736. present.append(name)
  737. if present:
  738. generate_console_scripts(present)
  739. if absent:
  740. if online or wheels_dir:
  741. # Some packages are missing or aren't a suitable version,
  742. # install a suitable (possibly vendored) package.
  743. print(f"mkvenv: installing {', '.join(absent)}", file=sys.stderr)
  744. try:
  745. pip_install(args=absent, online=online, wheels_dir=wheels_dir)
  746. return None
  747. except subprocess.CalledProcessError:
  748. pass
  749. return diagnose(
  750. absent[0],
  751. online,
  752. wheels_dir,
  753. canary,
  754. )
  755. return None
  756. def ensure(
  757. dep_specs: Sequence[str],
  758. online: bool = False,
  759. wheels_dir: Optional[Union[str, Path]] = None,
  760. prog: Optional[str] = None,
  761. ) -> None:
  762. """
  763. Use pip to ensure we have the package specified by @dep_specs.
  764. If the package is already installed, do nothing. If online and
  765. wheels_dir are both provided, prefer packages found in wheels_dir
  766. first before connecting to PyPI.
  767. :param dep_specs:
  768. PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
  769. :param online: If True, fall back to PyPI.
  770. :param wheels_dir: If specified, search this path for packages.
  771. :param prog:
  772. If specified, use this program name for error diagnostics that will
  773. be presented to the user. e.g., 'sphinx-build' can be used as a
  774. bellwether for the presence of 'sphinx'.
  775. """
  776. if not HAVE_DISTLIB:
  777. raise Ouch("a usable distlib could not be found, please install it")
  778. # Convert the depspecs to a dictionary, as if they came
  779. # from a section in a pythondeps.toml file
  780. group: Dict[str, Dict[str, str]] = {}
  781. for spec in dep_specs:
  782. name = distlib.version.LegacyMatcher(spec).name
  783. group[name] = {}
  784. spec = spec.strip()
  785. pos = len(name)
  786. ver = spec[pos:].strip()
  787. if ver:
  788. group[name]["accepted"] = ver
  789. if prog:
  790. group[name]["canary"] = prog
  791. prog = None
  792. result = _do_ensure(group, online, wheels_dir)
  793. if result:
  794. # Well, that's not good.
  795. if result[1]:
  796. raise Ouch(result[0])
  797. raise SystemExit(f"\n{result[0]}\n\n")
  798. def _parse_groups(file: str) -> Dict[str, Dict[str, Any]]:
  799. if not HAVE_TOMLLIB:
  800. if sys.version_info < (3, 11):
  801. raise Ouch("found no usable tomli, please install it")
  802. raise Ouch(
  803. "Python >=3.11 does not have tomllib... what have you done!?"
  804. )
  805. # Use loads() to support both tomli v1.2.x (Ubuntu 22.04,
  806. # Debian bullseye-backports) and v2.0.x
  807. with open(file, "r", encoding="ascii") as depfile:
  808. contents = depfile.read()
  809. return tomllib.loads(contents) # type: ignore
  810. def ensure_group(
  811. file: str,
  812. groups: Sequence[str],
  813. online: bool = False,
  814. wheels_dir: Optional[Union[str, Path]] = None,
  815. ) -> None:
  816. """
  817. Use pip to ensure we have the package specified by @dep_specs.
  818. If the package is already installed, do nothing. If online and
  819. wheels_dir are both provided, prefer packages found in wheels_dir
  820. first before connecting to PyPI.
  821. :param dep_specs:
  822. PEP 508 dependency specifications. e.g. ['meson>=0.61.5'].
  823. :param online: If True, fall back to PyPI.
  824. :param wheels_dir: If specified, search this path for packages.
  825. """
  826. if not HAVE_DISTLIB:
  827. raise Ouch("found no usable distlib, please install it")
  828. parsed_deps = _parse_groups(file)
  829. to_install: Dict[str, Dict[str, str]] = {}
  830. for group in groups:
  831. try:
  832. to_install.update(parsed_deps[group])
  833. except KeyError as exc:
  834. raise Ouch(f"group {group} not defined") from exc
  835. result = _do_ensure(to_install, online, wheels_dir)
  836. if result:
  837. # Well, that's not good.
  838. if result[1]:
  839. raise Ouch(result[0])
  840. raise SystemExit(f"\n{result[0]}\n\n")
  841. def post_venv_setup() -> None:
  842. """
  843. This is intended to be run *inside the venv* after it is created.
  844. """
  845. logger.debug("post_venv_setup()")
  846. # Test for a broken pip (Debian 10 or derivative?) and fix it if needed
  847. if not checkpip():
  848. # Finally, generate a 'pip' script so the venv is usable in a normal
  849. # way from the CLI. This only happens when we inherited pip from a
  850. # parent/system-site and haven't run ensurepip in some way.
  851. generate_console_scripts(["pip"])
  852. def _add_create_subcommand(subparsers: Any) -> None:
  853. subparser = subparsers.add_parser("create", help="create a venv")
  854. subparser.add_argument(
  855. "target",
  856. type=str,
  857. action="store",
  858. help="Target directory to install virtual environment into.",
  859. )
  860. def _add_post_init_subcommand(subparsers: Any) -> None:
  861. subparsers.add_parser("post_init", help="post-venv initialization")
  862. def _add_ensuregroup_subcommand(subparsers: Any) -> None:
  863. subparser = subparsers.add_parser(
  864. "ensuregroup",
  865. help="Ensure that the specified package group is installed.",
  866. )
  867. subparser.add_argument(
  868. "--online",
  869. action="store_true",
  870. help="Install packages from PyPI, if necessary.",
  871. )
  872. subparser.add_argument(
  873. "--dir",
  874. type=str,
  875. action="store",
  876. help="Path to vendored packages where we may install from.",
  877. )
  878. subparser.add_argument(
  879. "file",
  880. type=str,
  881. action="store",
  882. help=("Path to a TOML file describing package groups"),
  883. )
  884. subparser.add_argument(
  885. "group",
  886. type=str,
  887. action="store",
  888. help="One or more package group names",
  889. nargs="+",
  890. )
  891. def _add_ensure_subcommand(subparsers: Any) -> None:
  892. subparser = subparsers.add_parser(
  893. "ensure", help="Ensure that the specified package is installed."
  894. )
  895. subparser.add_argument(
  896. "--online",
  897. action="store_true",
  898. help="Install packages from PyPI, if necessary.",
  899. )
  900. subparser.add_argument(
  901. "--dir",
  902. type=str,
  903. action="store",
  904. help="Path to vendored packages where we may install from.",
  905. )
  906. subparser.add_argument(
  907. "--diagnose",
  908. type=str,
  909. action="store",
  910. help=(
  911. "Name of a shell utility to use for "
  912. "diagnostics if this command fails."
  913. ),
  914. )
  915. subparser.add_argument(
  916. "dep_specs",
  917. type=str,
  918. action="store",
  919. help="PEP 508 Dependency specification, e.g. 'meson>=0.61.5'",
  920. nargs="+",
  921. )
  922. def main() -> int:
  923. """CLI interface to make_qemu_venv. See module docstring."""
  924. if os.environ.get("DEBUG") or os.environ.get("GITLAB_CI"):
  925. # You're welcome.
  926. logging.basicConfig(level=logging.DEBUG)
  927. else:
  928. if os.environ.get("V"):
  929. logging.basicConfig(level=logging.INFO)
  930. parser = argparse.ArgumentParser(
  931. prog="mkvenv",
  932. description="QEMU pyvenv bootstrapping utility",
  933. )
  934. subparsers = parser.add_subparsers(
  935. title="Commands",
  936. dest="command",
  937. required=True,
  938. metavar="command",
  939. help="Description",
  940. )
  941. _add_create_subcommand(subparsers)
  942. _add_post_init_subcommand(subparsers)
  943. _add_ensure_subcommand(subparsers)
  944. _add_ensuregroup_subcommand(subparsers)
  945. args = parser.parse_args()
  946. try:
  947. if args.command == "create":
  948. make_venv(
  949. args.target,
  950. system_site_packages=True,
  951. clear=True,
  952. )
  953. if args.command == "post_init":
  954. post_venv_setup()
  955. if args.command == "ensure":
  956. ensure(
  957. dep_specs=args.dep_specs,
  958. online=args.online,
  959. wheels_dir=args.dir,
  960. prog=args.diagnose,
  961. )
  962. if args.command == "ensuregroup":
  963. ensure_group(
  964. file=args.file,
  965. groups=args.group,
  966. online=args.online,
  967. wheels_dir=args.dir,
  968. )
  969. logger.debug("mkvenv.py %s: exiting", args.command)
  970. except Ouch as exc:
  971. print("\n*** Ouch! ***\n", file=sys.stderr)
  972. print(str(exc), "\n\n", file=sys.stderr)
  973. return 1
  974. except SystemExit:
  975. raise
  976. except: # pylint: disable=bare-except
  977. logger.exception("mkvenv did not complete successfully:")
  978. return 2
  979. return 0
  980. if __name__ == "__main__":
  981. sys.exit(main())