pyi_rth_pkgres.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. #-----------------------------------------------------------------------------
  2. # Copyright (c) 2013-2023, PyInstaller Development Team.
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. #
  7. # The full license is in the file COPYING.txt, distributed with this software.
  8. #
  9. # SPDX-License-Identifier: Apache-2.0
  10. #-----------------------------------------------------------------------------
  11. # To make pkg_resources work with frozen modules, we need to set the 'Provider' class for PyiFrozenLoader.
  12. # This class decides where to look for resources and other stuff.
  13. #
  14. # 'pkg_resources.NullProvider' is dedicated to abitrary PEP302 loaders, such as our PyiFrozenLoader. It uses method
  15. # __loader__.get_data() in methods pkg_resources.resource_string() and pkg_resources.resource_stream().
  16. #
  17. # We provide PyiFrozenProvider, which subclasses the NullProvider and implements _has(), _isdir(), and _listdir()
  18. # methods, which are needed for pkg_resources.resource_exists(), resource_isdir(), and resource_listdir() to work. We
  19. # cannot use the DefaultProvider, because it provides filesystem-only implementations (and overrides _get() with a
  20. # filesystem-only one), whereas our provider needs to also support embedded resources.
  21. #
  22. # The PyiFrozenProvider allows querying/listing both PYZ-embedded and on-filesystem resources in a frozen package. The
  23. # results are typically combined for both types of resources (e.g., when listing a directory or checking whether a
  24. # resource exists). When the order of precedence matters, the PYZ-embedded resources take precedence over the
  25. # on-filesystem ones, to keep the behavior consistent with the actual file content retrieval via _get() method (which in
  26. # turn uses PyiFrozenLoader's get_data() method). For example, when checking whether a resource is a directory via
  27. # _isdir(), a PYZ-embedded file will take precedence over a potential on-filesystem directory. Also, in contrast to
  28. # unfrozen packages, the frozen ones do not contain source .py files, which are therefore absent from content listings.
  29. def _pyi_rthook():
  30. import os
  31. import pathlib
  32. import sys
  33. import warnings
  34. with warnings.catch_warnings():
  35. warnings.filterwarnings(
  36. "ignore",
  37. category=UserWarning,
  38. message="pkg_resources is deprecated",
  39. )
  40. import pkg_resources
  41. import pyimod02_importers # PyInstaller's bootstrap module
  42. SYS_PREFIX = pathlib.PurePath(sys._MEIPASS)
  43. class _TocFilesystem:
  44. """
  45. A prefix tree implementation for embedded filesystem reconstruction.
  46. NOTE: as of PyInstaller 6.0, the embedded PYZ archive cannot contain data files anymore. Instead, it contains
  47. only .pyc modules - which are by design not returned by `PyiFrozenProvider`. So this implementation has been
  48. reduced to supporting only directories implied by collected packages.
  49. """
  50. def __init__(self, tree_node):
  51. self._tree = tree_node
  52. def _get_tree_node(self, path):
  53. path = pathlib.PurePath(path)
  54. current = self._tree
  55. for component in path.parts:
  56. if component not in current:
  57. return None
  58. current = current[component]
  59. return current
  60. def path_exists(self, path):
  61. node = self._get_tree_node(path)
  62. return isinstance(node, dict) # Directory only
  63. def path_isdir(self, path):
  64. node = self._get_tree_node(path)
  65. return isinstance(node, dict) # Directory only
  66. def path_listdir(self, path):
  67. node = self._get_tree_node(path)
  68. if not isinstance(node, dict):
  69. return [] # Non-existent or file
  70. # Return only sub-directories
  71. return [entry_name for entry_name, entry_data in node.items() if isinstance(entry_data, dict)]
  72. class PyiFrozenProvider(pkg_resources.NullProvider):
  73. """
  74. Custom pkg_resources provider for PyiFrozenLoader.
  75. """
  76. def __init__(self, module):
  77. super().__init__(module)
  78. # Get top-level path; if "module" corresponds to a package, we need the path to the package itself.
  79. # If "module" is a submodule in a package, we need the path to the parent package.
  80. #
  81. # This is equivalent to `pkg_resources.NullProvider.module_path`, except we construct a `pathlib.PurePath`
  82. # for easier manipulation.
  83. #
  84. # NOTE: the path is NOT resolved for symbolic links, as neither are paths that are passed by `pkg_resources`
  85. # to `_has`, `_isdir`, `_listdir` (they are all anchored to `module_path`, which in turn is just
  86. # `os.path.dirname(module.__file__)`. As `__file__` returned by `PyiFrozenLoader` is always anchored to
  87. # `sys._MEIPASS`, we do not have to worry about cross-linked directories in macOS .app bundles, where the
  88. # resolved `__file__` could be either in the `Contents/Frameworks` directory (the "true" `sys._MEIPASS`), or
  89. # in the `Contents/Resources` directory due to cross-linking.
  90. self._pkg_path = pathlib.PurePath(module.__file__).parent
  91. # Construct _TocFilesystem on top of pre-computed prefix tree provided by pyimod02_importers.
  92. self.embedded_tree = _TocFilesystem(pyimod02_importers.get_pyz_toc_tree())
  93. def _normalize_path(self, path):
  94. # Avoid using `Path.resolve`, because it resolves symlinks. This is undesirable, because the pure path in
  95. # `self._pkg_path` does not have symlinks resolved, so comparison between the two would be faulty. Instead,
  96. # use `os.path.normpath` to normalize the path and get rid of any '..' elements (the path itself should
  97. # already be absolute).
  98. return pathlib.Path(os.path.normpath(path))
  99. def _is_relative_to_package(self, path):
  100. return path == self._pkg_path or self._pkg_path in path.parents
  101. def _has(self, path):
  102. # Prevent access outside the package.
  103. path = self._normalize_path(path)
  104. if not self._is_relative_to_package(path):
  105. return False
  106. # Check the filesystem first to avoid unnecessarily computing the relative path...
  107. if path.exists():
  108. return True
  109. rel_path = path.relative_to(SYS_PREFIX)
  110. return self.embedded_tree.path_exists(rel_path)
  111. def _isdir(self, path):
  112. # Prevent access outside the package.
  113. path = self._normalize_path(path)
  114. if not self._is_relative_to_package(path):
  115. return False
  116. # Embedded resources have precedence over filesystem...
  117. rel_path = path.relative_to(SYS_PREFIX)
  118. node = self.embedded_tree._get_tree_node(rel_path)
  119. if node is None:
  120. return path.is_dir() # No match found; try the filesystem.
  121. else:
  122. # str = file, dict = directory
  123. return not isinstance(node, str)
  124. def _listdir(self, path):
  125. # Prevent access outside the package.
  126. path = self._normalize_path(path)
  127. if not self._is_relative_to_package(path):
  128. return []
  129. # Relative path for searching embedded resources.
  130. rel_path = path.relative_to(SYS_PREFIX)
  131. # List content from embedded filesystem...
  132. content = self.embedded_tree.path_listdir(rel_path)
  133. # ... as well as the actual one.
  134. if path.is_dir():
  135. # Use os.listdir() to avoid having to convert Path objects to strings... Also make sure to de-duplicate
  136. # the results.
  137. path = str(path) # not is_py36
  138. content = list(set(content + os.listdir(path)))
  139. return content
  140. pkg_resources.register_loader_type(pyimod02_importers.PyiFrozenLoader, PyiFrozenProvider)
  141. # With our PyiFrozenFinder now being a path entry finder, it effectively replaces python's FileFinder. So we need
  142. # to register it with `pkg_resources.find_on_path` to allow metadata to be found on filesystem.
  143. pkg_resources.register_finder(pyimod02_importers.PyiFrozenFinder, pkg_resources.find_on_path)
  144. # For the above change to fully take effect, we need to re-initialize pkg_resources's master working set (since the
  145. # original one was built with assumption that sys.path entries are handled by python's FileFinder).
  146. # See https://github.com/pypa/setuptools/issues/373
  147. if hasattr(pkg_resources, '_initialize_master_working_set'):
  148. pkg_resources._initialize_master_working_set()
  149. _pyi_rthook()
  150. del _pyi_rthook