setuptools.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. # ----------------------------------------------------------------------------
  2. # Copyright (c) 2024, 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. from PyInstaller import log as logging
  12. from PyInstaller import isolated
  13. logger = logging.getLogger(__name__)
  14. # Import setuptools and analyze its properties in an isolated subprocess. This function is called by `SetuptoolsInfo`
  15. # to initialize its properties.
  16. @isolated.decorate
  17. def _retrieve_setuptools_info():
  18. import importlib
  19. try:
  20. setuptools = importlib.import_module("setuptools") # noqa: F841
  21. except ModuleNotFoundError:
  22. return None
  23. # Delay these imports until after we have confirmed that setuptools is importable.
  24. import pathlib
  25. import packaging.version
  26. from PyInstaller.compat import importlib_metadata
  27. from PyInstaller.utils.hooks import (
  28. collect_data_files,
  29. collect_submodules,
  30. )
  31. # Try to retrieve the version. At this point, failure is consider an error.
  32. version_string = importlib_metadata.version("setuptools")
  33. version = packaging.version.Version(version_string).release # Use the version tuple
  34. # setuptools >= 60.0 its vendored copy of distutils (mainly due to its removal from stdlib in python >= 3.12).
  35. distutils_vendored = False
  36. distutils_modules = []
  37. if version >= (60, 0):
  38. distutils_vendored = True
  39. distutils_modules += ["_distutils_hack"]
  40. distutils_modules += collect_submodules(
  41. "setuptools._distutils",
  42. # setuptools 71.0.1 ~ 71.0.4 include `setuptools._distutils.tests`; avoid explicitly collecting it
  43. # (t was not included in earlier setuptools releases).
  44. filter=lambda name: name != 'setuptools._distutils.tests',
  45. )
  46. # Check if `setuptools._vendor` exists. Some linux distributions opt to de-vendor `setuptools` and remove the
  47. # `setuptools._vendor` directory altogether. If this is the case, most of additional processing below should be
  48. # skipped to avoid errors and warnings about non-existent `setuptools._vendor` module.
  49. try:
  50. setuptools_vendor = importlib.import_module("setuptools._vendor")
  51. except ModuleNotFoundError:
  52. setuptools_vendor = None
  53. # Check for exposed packages/modules that are vendored by setuptools. If stand-alone version is not provided in the
  54. # environment, setuptools-vendored version is exposed (due to location of `setuptools._vendor` being appended to
  55. # `sys.path`. Applicable to v71.0.0 and later.
  56. vendored_status = dict()
  57. vendored_namespace_package_paths = dict()
  58. if version >= (71, 0) and setuptools_vendor is not None:
  59. VENDORED_TOP_LEVEL_NAMESPACE_CANDIDATES = (
  60. "backports", # "regular" package, but has namespace semantics due to `pkgutil.extend_path()`
  61. "jaraco", # PEP-420 namespace package
  62. )
  63. VENDORED_CANDIDATES = (
  64. "autocommand",
  65. "backports.tarfile",
  66. "importlib_metadata",
  67. "importlib_resources",
  68. "inflect",
  69. "jaraco.context",
  70. "jaraco.functools",
  71. "jaraco.text",
  72. "more_itertools",
  73. "ordered_set",
  74. "packaging",
  75. "platformdirs",
  76. "tomli",
  77. "typeguard",
  78. "typing_extensions",
  79. "wheel",
  80. "zipp",
  81. )
  82. # Resolve path(s) of `setuptools_vendor` package.
  83. setuptools_vendor_paths = [pathlib.Path(path).resolve() for path in setuptools_vendor.__path__]
  84. # Process each candidate: top-level namespace packages
  85. for candidate_name in VENDORED_TOP_LEVEL_NAMESPACE_CANDIDATES:
  86. try:
  87. candidate = importlib.import_module(candidate_name)
  88. except ImportError:
  89. continue
  90. # Retrieve the __path__ attribute and store it, so we can re-use it in hooks without having to re-import
  91. # `setuptools` and the candidate package...
  92. candidate_path_attr = getattr(candidate, '__path__', [])
  93. if candidate_path_attr:
  94. candidate_paths = [pathlib.Path(path).resolve() for path in candidate_path_attr]
  95. is_vendored = [
  96. any([
  97. setuptools_vendor_path in candidate_path.parents or candidate_path == setuptools_vendor_path
  98. for setuptools_vendor_path in setuptools_vendor_paths
  99. ]) for candidate_path in candidate_paths
  100. ]
  101. # For namespace packages, distinguish between "fully" vendored and "partially" vendored state; i.e.,
  102. # whether the part of namespace package in the vendored directory is the only part or not.
  103. if all(is_vendored):
  104. vendored_status[candidate_name] = 'fully'
  105. elif any(is_vendored):
  106. vendored_status[candidate_name] = 'partially'
  107. else:
  108. vendored_status[candidate_name] = False
  109. # Store paths
  110. vendored_namespace_package_paths[candidate_name] = [str(path) for path in candidate_path_attr]
  111. # Process each candidate: modules and packages
  112. for candidate_name in VENDORED_CANDIDATES:
  113. try:
  114. candidate = importlib.import_module(candidate_name)
  115. except ImportError:
  116. continue
  117. # Check the __file__ attribute (modules and regular packages). Will not work with namespace packages, but
  118. # at the moment, there are none (vendored top-level namespace packages have already been handled).
  119. candidate_file_attr = getattr(candidate, '__file__', None)
  120. if candidate_file_attr is not None:
  121. candidate_path = pathlib.Path(candidate_file_attr).parent.resolve()
  122. is_vendored = any([
  123. setuptools_vendor_path in candidate_path.parents or candidate_path == setuptools_vendor_path
  124. for setuptools_vendor_path in setuptools_vendor_paths
  125. ])
  126. vendored_status[candidate_name] = is_vendored # True/False
  127. # Collect submodules from `setuptools._vendor`, regardless of whether the vendored package is exposed or
  128. # not (because setuptools might need/use it either way).
  129. vendored_modules = []
  130. if setuptools_vendor is not None:
  131. EXCLUDED_VENDORED_MODULES = (
  132. # Prevent recursing into setuptools._vendor.pyparsing.diagram, which typically fails to be imported due
  133. # to missing dependencies (railroad, pyparsing (?), jinja2) and generates a warning... As the module is
  134. # usually unimportable, it is likely not to be used by setuptools. NOTE: pyparsing was removed from
  135. # vendored packages in setuptools v67.0.0; keep this exclude around for earlier versions.
  136. 'setuptools._vendor.pyparsing.diagram',
  137. # Setuptools >= 71 started shipping vendored dependencies that include tests; avoid collecting those via
  138. # hidden imports. (Note that this also prevents creation of aliases for these module, but that should
  139. # not be an issue, as they should not be referenced from anywhere).
  140. 'setuptools._vendor.importlib_resources.tests',
  141. # These appear to be utility scripts bundled with the jaraco.text package - exclude them.
  142. 'setuptools._vendor.jaraco.text.show-newlines',
  143. 'setuptools._vendor.jaraco.text.strip-prefix',
  144. 'setuptools._vendor.jaraco.text.to-dvorak',
  145. 'setuptools._vendor.jaraco.text.to-qwerty',
  146. )
  147. vendored_modules += collect_submodules(
  148. 'setuptools._vendor',
  149. filter=lambda name: name not in EXCLUDED_VENDORED_MODULES,
  150. )
  151. # `collect_submodules` (and its underlying `pkgutil.iter_modules` do not discover namespace sub-packages, in
  152. # this case `setuptools._vendor.jaraco`. So force a manual scan of modules/packages inside it.
  153. vendored_modules += collect_submodules(
  154. 'setuptools._vendor.jaraco',
  155. filter=lambda name: name not in EXCLUDED_VENDORED_MODULES,
  156. )
  157. # *** Data files for vendored packages ***
  158. vendored_data = []
  159. if version >= (71, 0) and setuptools_vendor is not None:
  160. # Since the vendored dependencies from `setuptools/_vendor` are now visible to the outside world, make
  161. # sure we collect their metadata. (We cannot use copy_metadata here, because we need to collect data
  162. # files to their original locations).
  163. vendored_data += collect_data_files('setuptools._vendor', includes=['**/*.dist-info'])
  164. # Similarly, ensure that `Lorem ipsum.txt` from vendored jaraco.text is collected
  165. vendored_data += collect_data_files('setuptools._vendor.jaraco.text', includes=['**/Lorem ipsum.txt'])
  166. # Return dictionary with collected information
  167. return {
  168. "available": True,
  169. "version": version,
  170. "distutils_vendored": distutils_vendored,
  171. "distutils_modules": distutils_modules,
  172. "vendored_status": vendored_status,
  173. "vendored_modules": vendored_modules,
  174. "vendored_data": vendored_data,
  175. "vendored_namespace_package_paths": vendored_namespace_package_paths,
  176. }
  177. class SetuptoolsInfo:
  178. def __init__(self):
  179. pass
  180. def __repr__(self):
  181. return "SetuptoolsInfo"
  182. # Delay initialization of setuptools information until until the corresponding attributes are first requested.
  183. def __getattr__(self, name):
  184. if 'available' in self.__dict__:
  185. # Initialization was already done, but requested attribute is not available.
  186. raise AttributeError(name)
  187. # Load setuptools info...
  188. self._load_setuptools_info()
  189. # ... and return the requested attribute
  190. return getattr(self, name)
  191. def _load_setuptools_info(self):
  192. logger.info("%s: initializing cached setuptools info...", self)
  193. # Initialize variables so that they might be accessed even if setuptools is unavailable or if initialization
  194. # fails for some reason.
  195. self.available = False
  196. self.version = None
  197. self.distutils_vendored = False
  198. self.distutils_modules = []
  199. self.vendored_status = dict()
  200. self.vendored_modules = []
  201. self.vendored_data = []
  202. self.vendored_namespace_package_paths = dict()
  203. try:
  204. setuptools_info = _retrieve_setuptools_info()
  205. except Exception as e:
  206. logger.warning("%s: failed to obtain setuptools info: %s", self, e)
  207. return
  208. # If package could not be imported, `_retrieve_setuptools_info` returns None. In such cases, emit a debug
  209. # message instead of a warning, because this initialization might be triggered by a helper function that is
  210. # trying to determine availability of `setuptools` by inspecting the `available` attribute.
  211. if setuptools_info is None:
  212. logger.debug("%s: failed to obtain setuptools info: setuptools could not be imported.", self)
  213. return
  214. # Copy properties
  215. for key, value in setuptools_info.items():
  216. setattr(self, key, value)
  217. def is_vendored(self, module_name):
  218. return self.vendored_status.get(module_name, False)
  219. @staticmethod
  220. def _create_vendored_aliases(vendored_name, module_name, modules_list):
  221. # Create aliases for all submodules
  222. prefix_len = len(vendored_name) # Length of target-name prefix to remove
  223. return ((module_name + vendored_module[prefix_len:], vendored_module) for vendored_module in modules_list
  224. if vendored_module.startswith(vendored_name))
  225. def get_vendored_aliases(self, module_name):
  226. vendored_name = f"setuptools._vendor.{module_name}"
  227. return self._create_vendored_aliases(vendored_name, module_name, self.vendored_modules)
  228. def get_distutils_aliases(self):
  229. vendored_name = "setuptools._distutils"
  230. return self._create_vendored_aliases(vendored_name, "distutils", self.distutils_modules)
  231. setuptools_info = SetuptoolsInfo()
  232. def pre_safe_import_module_for_top_level_namespace_packages(api):
  233. """
  234. A common implementation of pre_safe_import_module hook function for handling vendored top-level namespace packages
  235. (i.e., `backports` and `jaraco`).
  236. This function can be either called from the `pre_safe_import_module` function in a pre-safe-import-module hook, or
  237. just imported into the hook and aliased to `pre_safe_import_module`.
  238. """
  239. module_name = api.module_name
  240. # Check if the package/module is a vendored copy. This also returns False is setuptools is unavailable, because
  241. # vendored module status dictionary will be empty.
  242. vendored = setuptools_info.is_vendored(module_name)
  243. if not vendored:
  244. return
  245. if vendored == 'fully':
  246. # For a fully-vendored copy, force creation of aliases; on one hand, this aims to ensure that submodules are
  247. # resolvable, but on the other, it also prevents creation of unvendored top-level package, which should not
  248. # exit in this case.
  249. vendored_name = f"setuptools._vendor.{module_name}"
  250. logger.info(
  251. "Setuptools: %r appears to be a full setuptools-vendored copy - creating alias to %r!", module_name,
  252. vendored_name
  253. )
  254. # Create aliases for all (sub)modules
  255. for aliased_name, real_vendored_name in setuptools_info.get_vendored_aliases(module_name):
  256. api.add_alias_module(real_vendored_name, aliased_name)
  257. elif vendored == 'partially':
  258. # For a partially-vendored copy, adjust the submodule search paths, so that submodules from all locations are
  259. # discoverable (especially from the setuptools vendor directory, which might not be in the search path yet).
  260. search_paths = setuptools_info.vendored_namespace_package_paths.get(module_name, [])
  261. logger.info(
  262. "Setuptools: %r appears to be a partial setuptools-vendored copy - extending search paths to %r!",
  263. module_name, search_paths
  264. )
  265. for path in search_paths:
  266. api.append_package_path(path)
  267. else:
  268. logger.warning("Setuptools: %r has unhandled vendored status: %r", module_name, vendored)
  269. def pre_safe_import_module(api):
  270. """
  271. A common implementation of pre_safe_import_module hook function.
  272. This function can be either called from the `pre_safe_import_module` function in a pre-safe-import-module hook, or
  273. just imported into the hook.
  274. """
  275. module_name = api.module_name
  276. # Check if the package/module is a vendored copy. This also returns False is setuptools is unavailable, because
  277. # vendored module status dictionary will be empty.
  278. if not setuptools_info.is_vendored(module_name):
  279. return
  280. vendored_name = f"setuptools._vendor.{module_name}"
  281. logger.info(
  282. "Setuptools: %r appears to be a setuptools-vendored copy - creating alias to %r!", module_name, vendored_name
  283. )
  284. # Create aliases for all (sub)modules
  285. for aliased_name, real_vendored_name in setuptools_info.get_vendored_aliases(module_name):
  286. api.add_alias_module(real_vendored_name, aliased_name)