_cache.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. # Licensed under the Apache License, Version 2.0 (the "License"); you may
  2. # not use this file except in compliance with the License. You may obtain
  3. # a copy of the License at
  4. #
  5. # http://www.apache.org/licenses/LICENSE-2.0
  6. #
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  9. # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  10. # License for the specific language governing permissions and limitations
  11. # under the License.
  12. """Use a cache layer in front of entry point scanning."""
  13. import errno
  14. import glob
  15. import hashlib
  16. import importlib.metadata as importlib_metadata
  17. import itertools
  18. import json
  19. import logging
  20. import os
  21. import os.path
  22. import struct
  23. import sys
  24. log = logging.getLogger('stevedore._cache')
  25. def _get_cache_dir():
  26. """Locate a platform-appropriate cache directory to use.
  27. Does not ensure that the cache directory exists.
  28. """
  29. # Linux, Unix, AIX, etc.
  30. if os.name == 'posix' and sys.platform != 'darwin':
  31. # use ~/.cache if empty OR not set
  32. base_path = os.environ.get("XDG_CACHE_HOME", None) \
  33. or os.path.expanduser('~/.cache')
  34. return os.path.join(base_path, 'python-entrypoints')
  35. # Mac OS
  36. elif sys.platform == 'darwin':
  37. return os.path.expanduser('~/Library/Caches/Python Entry Points')
  38. # Windows (hopefully)
  39. else:
  40. base_path = os.environ.get('LOCALAPPDATA', None) \
  41. or os.path.expanduser('~\\AppData\\Local')
  42. return os.path.join(base_path, 'Python Entry Points')
  43. def _get_mtime(name):
  44. try:
  45. s = os.stat(name)
  46. return s.st_mtime
  47. except OSError as err:
  48. if err.errno not in {errno.ENOENT, errno.ENOTDIR}:
  49. raise
  50. return -1.0
  51. def _ftobytes(f):
  52. return struct.Struct('f').pack(f)
  53. def _hash_settings_for_path(path):
  54. """Return a hash and the path settings that created it."""
  55. paths = []
  56. h = hashlib.sha256()
  57. # Tie the cache to the python interpreter, in case it is part of a
  58. # virtualenv.
  59. h.update(sys.executable.encode('utf-8'))
  60. h.update(sys.prefix.encode('utf-8'))
  61. for entry in path:
  62. mtime = _get_mtime(entry)
  63. h.update(entry.encode('utf-8'))
  64. h.update(_ftobytes(mtime))
  65. paths.append((entry, mtime))
  66. for ep_file in itertools.chain(
  67. glob.iglob(os.path.join(entry,
  68. '*.dist-info',
  69. 'entry_points.txt')),
  70. glob.iglob(os.path.join(entry,
  71. '*.egg-info',
  72. 'entry_points.txt'))
  73. ):
  74. mtime = _get_mtime(ep_file)
  75. h.update(ep_file.encode('utf-8'))
  76. h.update(_ftobytes(mtime))
  77. paths.append((ep_file, mtime))
  78. return (h.hexdigest(), paths)
  79. def _build_cacheable_data():
  80. real_groups = importlib_metadata.entry_points()
  81. if not isinstance(real_groups, dict):
  82. # importlib-metadata 4.0 or later (or stdlib importlib.metadata in
  83. # Python 3.9 or later)
  84. real_groups = {
  85. group: real_groups.select(group=group)
  86. for group in real_groups.groups
  87. }
  88. # Convert the namedtuple values to regular tuples
  89. groups = {}
  90. for name, group_data in real_groups.items():
  91. existing = set()
  92. members = []
  93. groups[name] = members
  94. for ep in group_data:
  95. # Filter out duplicates that can occur when testing a
  96. # package that provides entry points using tox, where the
  97. # package is installed in the virtualenv that tox builds
  98. # and is present in the path as '.'.
  99. item = ep.name, ep.value, ep.group # convert to tuple
  100. if item in existing:
  101. continue
  102. existing.add(item)
  103. members.append(item)
  104. return {
  105. 'groups': groups,
  106. 'sys.executable': sys.executable,
  107. 'sys.prefix': sys.prefix,
  108. }
  109. class Cache:
  110. def __init__(self, cache_dir=None):
  111. if cache_dir is None:
  112. cache_dir = _get_cache_dir()
  113. self._dir = cache_dir
  114. self._internal = {}
  115. self._disable_caching = False
  116. # Caching can be disabled by either placing .disable file into the
  117. # target directory or when python executable is under /tmp (this is the
  118. # case when executed from ansible)
  119. if any([os.path.isfile(os.path.join(self._dir, '.disable')),
  120. sys.executable[0:4] == '/tmp']):
  121. self._disable_caching = True
  122. def _get_data_for_path(self, path):
  123. if path is None:
  124. path = sys.path
  125. internal_key = tuple(path)
  126. if internal_key in self._internal:
  127. return self._internal[internal_key]
  128. digest, path_values = _hash_settings_for_path(path)
  129. filename = os.path.join(self._dir, digest)
  130. try:
  131. log.debug('reading %s', filename)
  132. with open(filename, 'r') as f:
  133. data = json.load(f)
  134. except (IOError, json.JSONDecodeError):
  135. data = _build_cacheable_data()
  136. data['path_values'] = path_values
  137. if not self._disable_caching:
  138. try:
  139. log.debug('writing to %s', filename)
  140. os.makedirs(self._dir, exist_ok=True)
  141. with open(filename, 'w') as f:
  142. json.dump(data, f)
  143. except (IOError, OSError):
  144. # Could not create cache dir or write file.
  145. pass
  146. self._internal[internal_key] = data
  147. return data
  148. def get_group_all(self, group, path=None):
  149. result = []
  150. data = self._get_data_for_path(path)
  151. group_data = data.get('groups', {}).get(group, [])
  152. for vals in group_data:
  153. result.append(importlib_metadata.EntryPoint(*vals))
  154. return result
  155. def get_group_named(self, group, path=None):
  156. result = {}
  157. for ep in self.get_group_all(group, path=path):
  158. if ep.name not in result:
  159. result[ep.name] = ep
  160. return result
  161. def get_single(self, group, name, path=None):
  162. for name, ep in self.get_group_named(group, path=path).items():
  163. if name == name:
  164. return ep
  165. raise ValueError('No entrypoint {!r} in group {!r}'.format(
  166. group, name))
  167. _c = Cache()
  168. get_group_all = _c.get_group_all
  169. get_group_named = _c.get_group_named
  170. get_single = _c.get_single