conda.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. #-----------------------------------------------------------------------------
  2. # Copyright (c) 2005-2023, PyInstaller Development Team.
  3. #
  4. # Distributed under the terms of the GNU General Public License (version 2
  5. # or later) with exception for distributing the bootloader.
  6. #
  7. # The full license is in the file COPYING.txt, distributed with this software.
  8. #
  9. # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
  10. #-----------------------------------------------------------------------------
  11. # language=rst
  12. """
  13. Additional helper methods for working specifically with Anaconda distributions are found at
  14. :mod:`PyInstaller.utils.hooks.conda_support<PyInstaller.utils.hooks.conda>`
  15. which is designed to mimic (albeit loosely) the `importlib.metadata`_ package. These functions find and parse the
  16. distribution metadata from json files located in the ``conda-meta`` directory.
  17. .. versionadded:: 4.2.0
  18. This module is available only if run inside a Conda environment. Usage of this module should therefore be wrapped in
  19. a conditional clause::
  20. from PyInstaller.compat import is_pure_conda
  21. if is_pure_conda:
  22. from PyInstaller.utils.hooks import conda_support
  23. # Code goes here. e.g.
  24. binaries = conda_support.collect_dynamic_libs("numpy")
  25. ...
  26. Packages are all referenced by the *distribution name* you use to install it, rather than the *package name* you import
  27. it with. I.e., use ``distribution("pillow")`` instead of ``distribution("PIL")`` or use ``package_distribution("PIL")``.
  28. """
  29. from __future__ import annotations
  30. import fnmatch
  31. import json
  32. import pathlib
  33. import sys
  34. from typing import Iterable, List
  35. from importlib.metadata import PackagePath as _PackagePath
  36. from PyInstaller import compat
  37. from PyInstaller.log import logger
  38. # Conda virtual environments each get their own copy of `conda-meta` so the use of `sys.prefix` instead of
  39. # `sys.base_prefix`, `sys.real_prefix` or anything from our `compat` module is intentional.
  40. CONDA_ROOT = pathlib.Path(sys.prefix)
  41. CONDA_META_DIR = CONDA_ROOT / "conda-meta"
  42. # Find all paths in `sys.path` that are inside Conda root.
  43. PYTHONPATH_PREFIXES = []
  44. for _path in sys.path:
  45. _path = pathlib.Path(_path)
  46. try:
  47. PYTHONPATH_PREFIXES.append(_path.relative_to(sys.prefix))
  48. except ValueError:
  49. pass
  50. PYTHONPATH_PREFIXES.sort(key=lambda p: len(p.parts), reverse=True)
  51. class Distribution:
  52. """
  53. A bucket class representation of a Conda distribution.
  54. This bucket exports the following attributes:
  55. :ivar name: The distribution's name.
  56. :ivar version: Its version.
  57. :ivar files: All filenames as :meth:`PackagePath`\\ s included with this distribution.
  58. :ivar dependencies: Names of other distributions that this distribution depends on (with version constraints
  59. removed).
  60. :ivar packages: Names of importable packages included in this distribution.
  61. This class is not intended to be constructed directly by users. Rather use :meth:`distribution` or
  62. :meth:`package_distribution` to provide one for you.
  63. """
  64. def __init__(self, json_path: str):
  65. try:
  66. self._json_path = pathlib.Path(json_path)
  67. assert self._json_path.exists()
  68. except (TypeError, AssertionError):
  69. raise TypeError(
  70. "Distribution requires a path to a conda-meta json. Perhaps you want "
  71. "`distribution({})` instead?".format(repr(json_path))
  72. )
  73. # Everything we need (including this distribution's name) is kept in the metadata json.
  74. self.raw: dict = json.loads(self._json_path.read_text())
  75. # Unpack the more useful contents of the json.
  76. self.name: str = self.raw["name"]
  77. self.version: str = self.raw["version"]
  78. self.files = [PackagePath(i) for i in self.raw["files"]]
  79. self.dependencies = self._init_dependencies()
  80. self.packages = self._init_package_names()
  81. def __repr__(self):
  82. return "{}(name=\"{}\", packages={})".format(type(self).__name__, self.name, self.packages)
  83. def _init_dependencies(self):
  84. """
  85. Read dependencies from ``self.raw["depends"]``.
  86. :return: Dependent distribution names.
  87. :rtype: list
  88. The names in ``self.raw["depends"]`` come with extra version constraint information which must be stripped.
  89. """
  90. dependencies = []
  91. # For each dependency:
  92. for dependency in self.raw.get("depends", []):
  93. # ``dependency`` is a string of the form: "[name] [version constraints]"
  94. name, *version_constraints = dependency.split(maxsplit=1)
  95. dependencies.append(name)
  96. return dependencies
  97. def _init_package_names(self):
  98. """
  99. Search ``self.files`` for package names shipped by this distribution.
  100. :return: Package names.
  101. :rtype: list
  102. These are names you would ``import`` rather than names you would install.
  103. """
  104. packages = []
  105. for file in self.files:
  106. package = _get_package_name(file)
  107. if package is not None:
  108. packages.append(package)
  109. return packages
  110. @classmethod
  111. def from_name(cls, name: str):
  112. """
  113. Get distribution information for a given distribution **name** (i.e., something you would ``conda install``).
  114. :rtype: :class:`Distribution`
  115. """
  116. if name in distributions:
  117. return distributions[name]
  118. raise ModuleNotFoundError(
  119. "Distribution {} is either not installed or was not installed using Conda.".format(name)
  120. )
  121. @classmethod
  122. def from_package_name(cls, name: str):
  123. """
  124. Get distribution information for a **package** (i.e., something you would import).
  125. :rtype: :class:`Distribution`
  126. For example, the package ``pkg_resources`` belongs to the distribution ``setuptools``, which contains three
  127. packages.
  128. >>> package_distribution("pkg_resources")
  129. Distribution(name="setuptools",
  130. packages=['easy_install', 'pkg_resources', 'setuptools'])
  131. """
  132. if name in distributions_by_package:
  133. return distributions_by_package[name]
  134. raise ModuleNotFoundError("Package {} is either not installed or was not installed using Conda.".format(name))
  135. distribution = Distribution.from_name
  136. package_distribution = Distribution.from_package_name
  137. class PackagePath(_PackagePath):
  138. """
  139. A filename relative to Conda's root (``sys.prefix``).
  140. This class inherits from :class:`pathlib.PurePosixPath` even on non-Posix OSs. To convert to a :class:`pathlib.Path`
  141. pointing to the real file, use the :meth:`locate` method.
  142. """
  143. def locate(self):
  144. """
  145. Return a path-like object for this path pointing to the file's true location.
  146. """
  147. return pathlib.Path(sys.prefix) / self
  148. def walk_dependency_tree(initial: str, excludes: Iterable[str] | None = None):
  149. """
  150. Collect a :class:`Distribution` and all direct and indirect dependencies of that distribution.
  151. Arguments:
  152. initial:
  153. Distribution name to collect from.
  154. excludes:
  155. Distributions to exclude.
  156. Returns:
  157. A ``{name: distribution}`` mapping where ``distribution`` is the output of
  158. :func:`conda_support.distribution(name) <distribution>`.
  159. """
  160. if excludes is not None:
  161. excludes = set(excludes)
  162. # Rather than use true recursion, mimic it with a to-do queue.
  163. from collections import deque
  164. done = {}
  165. names_to_do = deque([initial])
  166. while names_to_do:
  167. # Grab a distribution name from the to-do list.
  168. name = names_to_do.pop()
  169. try:
  170. # Collect and save it's metadata.
  171. done[name] = distribution = Distribution.from_name(name)
  172. logger.debug("Collected Conda distribution '%s', a dependency of '%s'.", name, initial)
  173. except ModuleNotFoundError:
  174. logger.warning(
  175. "Conda distribution '%s', dependency of '%s', was not found. "
  176. "If you installed this distribution with pip then you may ignore this warning.", name, initial
  177. )
  178. continue
  179. # For each dependency:
  180. for _name in distribution.dependencies:
  181. if _name in done:
  182. # Skip anything already done.
  183. continue
  184. if _name == name:
  185. # Avoid infinite recursion if a distribution depends on itself. This will probably never happen but I
  186. # certainly would not chance it.
  187. continue
  188. if excludes is not None and _name in excludes:
  189. # Do not recurse to excluded dependencies.
  190. continue
  191. names_to_do.append(_name)
  192. return done
  193. def _iter_distributions(name, dependencies, excludes):
  194. if dependencies:
  195. return walk_dependency_tree(name, excludes).values()
  196. else:
  197. return [Distribution.from_name(name)]
  198. def requires(name: str, strip_versions: bool = False) -> List[str]:
  199. """
  200. List requirements of a distribution.
  201. Arguments:
  202. name:
  203. The name of the distribution.
  204. strip_versions:
  205. List only their names, not their version constraints.
  206. Returns:
  207. A list of distribution names.
  208. """
  209. if strip_versions:
  210. return distribution(name).dependencies
  211. return distribution(name).raw["depends"]
  212. def files(name: str, dependencies: bool = False, excludes: list | None = None) -> List[PackagePath]:
  213. """
  214. List all files belonging to a distribution.
  215. Arguments:
  216. name:
  217. The name of the distribution.
  218. dependencies:
  219. Recursively collect files of dependencies too.
  220. excludes:
  221. Distributions to ignore if **dependencies** is true.
  222. Returns:
  223. All filenames belonging to the given distribution.
  224. With ``dependencies=False``, this is just a shortcut for::
  225. conda_support.distribution(name).files
  226. """
  227. return [file for dist in _iter_distributions(name, dependencies, excludes) for file in dist.files]
  228. if compat.is_win:
  229. lib_dir = pathlib.PurePath("Library", "bin")
  230. else:
  231. lib_dir = pathlib.PurePath("lib")
  232. def collect_dynamic_libs(name: str, dest: str = ".", dependencies: bool = True, excludes: Iterable[str] | None = None):
  233. """
  234. Collect DLLs for distribution **name**.
  235. Arguments:
  236. name:
  237. The distribution's project-name.
  238. dest:
  239. Target destination, defaults to ``'.'``.
  240. dependencies:
  241. Recursively collect libs for dependent distributions (recommended).
  242. excludes:
  243. Dependent distributions to skip, defaults to ``None``.
  244. Returns:
  245. List of DLLs in PyInstaller's ``(source, dest)`` format.
  246. This collects libraries only from Conda's shared ``lib`` (Unix) or ``Library/bin`` (Windows) folders. To collect
  247. from inside a distribution's installation use the regular :func:`PyInstaller.utils.hooks.collect_dynamic_libs`.
  248. """
  249. DLL_SUFFIXES = ("*.dll", "*.dylib", "*.so", "*.so.*")
  250. _files = []
  251. for file in files(name, dependencies, excludes):
  252. # A file is classified as a dynamic library if:
  253. # 1) it lives inside the dedicated ``lib_dir`` DLL folder.
  254. #
  255. # NOTE: `file` is an instance of `PackagePath`, which inherits from `pathlib.PurePosixPath` even on Windows.
  256. # Therefore, it does not properly handle cases when metadata paths contain Windows-style separator, which does
  257. # seem to be used on some Windows installations (see #9113). Therefore, cast `file` to `pathlib.PurePath`
  258. # before comparing its parent to `lib_dir` (which should also be a `pathlib.PurePath`).
  259. if pathlib.PurePath(file).parent != lib_dir:
  260. continue
  261. # 2) it is a file (and not a directory or a symbolic link pointing to a directory)
  262. resolved_file = file.locate()
  263. if not resolved_file.is_file():
  264. continue
  265. # 3) has a correct suffix
  266. if not any([resolved_file.match(suffix) for suffix in DLL_SUFFIXES]):
  267. continue
  268. _files.append((str(resolved_file), dest))
  269. return _files
  270. # --- Map packages to distributions and vice-versa ---
  271. def _get_package_name(file: PackagePath):
  272. """
  273. Determine the package name of a Python file in :data:`sys.path`.
  274. Arguments:
  275. file:
  276. A Python filename relative to Conda root (sys.prefix).
  277. Returns:
  278. Package name or None.
  279. This function only considers single file packages e.g. ``foo.py`` or top level ``foo/__init__.py``\\ s.
  280. Anything else is ignored (returning ``None``).
  281. """
  282. file = pathlib.Path(file)
  283. # TODO: Handle PEP 420 namespace packages (which are missing `__init__` module). No such Conda PEP 420 namespace
  284. # packages are known.
  285. # Get top-level folders by finding parents of `__init__.xyz`s
  286. if file.stem == "__init__" and file.suffix in compat.ALL_SUFFIXES:
  287. file = file.parent
  288. elif file.suffix not in compat.ALL_SUFFIXES:
  289. # Keep single-file packages but skip DLLs, data and junk files.
  290. return
  291. # Check if this file/folder's parent is in ``sys.path`` i.e. it's directly importable. This intentionally excludes
  292. # submodules which would cause confusion because ``sys.prefix`` is in ``sys.path``, meaning that every file in an
  293. # Conda installation is a submodule.
  294. for prefix in PYTHONPATH_PREFIXES:
  295. if len(file.parts) != len(prefix.parts) + 1:
  296. # This check is redundant but speeds it up quite a bit.
  297. continue
  298. # There are no wildcards involved here. The use of ``fnmatch`` is simply to handle the `if case-insensitive
  299. # file system: use case-insensitive string matching.`
  300. if fnmatch.fnmatch(str(file.parent), str(prefix)):
  301. return file.stem
  302. # All the information we want is organised the wrong way.
  303. # We want to look up distribution based on package names, but we can only search for packages using distribution names.
  304. # And we would like to search for a distribution's json file, but, due to the noisy filenames of the jsons, we can only
  305. # find a json's distribution rather than a distribution's json.
  306. # So we have to read everything, then regroup distributions in the ways we want them grouped. This will likely be a
  307. # spectacular bottleneck on full-blown Conda (non miniconda) with 250+ packages by default at several GiBs. I suppose we
  308. # could cache this on a per-json basis if it gets too much.
  309. def _init_distributions():
  310. distributions = {}
  311. for path in CONDA_META_DIR.glob("*.json"):
  312. dist = Distribution(path)
  313. distributions[dist.name] = dist
  314. return distributions
  315. distributions = _init_distributions()
  316. def _init_packages():
  317. distributions_by_package = {}
  318. for distribution in distributions.values():
  319. for package in distribution.packages:
  320. distributions_by_package[package] = distribution
  321. return distributions_by_package
  322. distributions_by_package = _init_packages()