cache.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. import collections
  2. import hashlib
  3. import os
  4. import shutil
  5. import svg
  6. import util
  7. class Cache:
  8. """
  9. Implements a cache system for emoji.
  10. This cache is implemented based on a cache directory where files are placed
  11. in directories by the format they were exported to; the individual cache
  12. files are named by their key, which is generated from:
  13. - the source file of the emoji;
  14. - the colour modifiers applied to the emoji;
  15. - the license, being applied to the files, if present;
  16. This defines how the emoji looks, and makes it such that a change in either
  17. the source or the manifest palette will not reuse the file in cache.
  18. """
  19. cache_dir = None
  20. # Formats for which a non-licensed version should never be cached
  21. skip_export_cache_formats = set(('svg'))
  22. def __init__(self, cache_dir):
  23. """
  24. Initiate an export cache instance. Requires a directory path (which may
  25. or may not exist already) to function.
  26. """
  27. if not isinstance(cache_dir, str):
  28. raise ValueError("Cache dir must be a string path")
  29. self.cache_dir = cache_dir
  30. self.initiate_cache_dir()
  31. def initiate_cache_dir(self):
  32. """Make the cache directory if it does not exist already."""
  33. if not os.path.exists(self.cache_dir):
  34. try:
  35. os.mkdir(self.cache_dir)
  36. except OSError as exc:
  37. raise RuntimeError("Cannot create cache directory "
  38. "'{}'".format(self.cache_dir)) from exc
  39. elif not os.path.isdir(self.cache_dir):
  40. raise RuntimeError("Cache path '{}' exists but is not a "
  41. "directory".format(self.cache_dir))
  42. return True
  43. @staticmethod
  44. def generate_cache_key_from_parts(key_parts):
  45. """
  46. Calculate a unique hash from the given key parts, building the data to
  47. feed to the algorithm from the repr() encoded as UTF-8.
  48. This should be stable as long as the inputs are the same, as we're
  49. using data structures with an order guarantee.
  50. """
  51. raw_key = bytes(repr(key_parts), 'utf-8')
  52. return hashlib.sha256(raw_key).hexdigest()
  53. @staticmethod
  54. def get_cache_keys(emoji, manifest, emoji_src, license_enabled):
  55. """
  56. Get the cache keys for a given emoji, base and for each license format
  57. if license_enabled is set.
  58. This needs to take into account multiple parts:
  59. - SVG source file: Allows tracking changes to the source
  60. - Colour modifiers, if applicable: Tracks changes in the manifest
  61. - License contents, only for each of the license formats
  62. """
  63. if 'cache_keys' in emoji:
  64. return emoji['cache_keys']
  65. src = emoji_src
  66. if isinstance(src, collections.abc.ByteString):
  67. src = bytes(src, 'utf-8')
  68. src_hash = hashlib.sha256(bytes(emoji_src, 'utf-8')).digest()
  69. # Find which variable colours are in this emoji
  70. colors = None
  71. if 'color' in emoji:
  72. pal_src, pal_dst = util.get_color_palettes(emoji, manifest)
  73. colors = []
  74. changed = svg.translated_colors(emoji_src, pal_src, pal_dst)
  75. colors = sorted(changed.items())
  76. # Collect the parts
  77. key_parts_base = (
  78. ('src_hash', src_hash),
  79. ('colors', colors),
  80. )
  81. key_base = Cache.generate_cache_key_from_parts(key_parts_base)
  82. key_licenses = None
  83. if license_enabled:
  84. key_licenses = {}
  85. for license in manifest.license:
  86. # Obtain the license to add to the key parts
  87. license_content = bytes(repr(manifest.license[license]),
  88. 'utf-8')
  89. key_parts = key_parts_base + (('license', license_content), )
  90. key = Cache.generate_cache_key_from_parts(key_parts)
  91. key_licenses[license] = key
  92. keys = {
  93. 'base': key_base,
  94. 'licenses': key_licenses,
  95. }
  96. return keys
  97. def build_emoji_cache_path(self, emoji, f, license_enabled):
  98. """
  99. Build the full path to the cache emoji file (regardless of existence).
  100. If `license_enabled` is `True` the path for the given format with
  101. license is built and returned.
  102. This requires the 'cache_keys' field of the emoji object that is passed
  103. to be present.
  104. If `license_enabled` is `True`, then the license type for the given
  105. format is used to build the path; if the format `f` does not support a
  106. license `None` is returned instead.
  107. """
  108. if 'cache_keys' not in emoji or 'base' not in emoji['cache_keys']:
  109. raise RuntimeError("Emoji '{}' does not have a cache key "
  110. "set!".format(emoji['short']))
  111. cache_key = None
  112. if not license_enabled and f in self.skip_export_cache_formats:
  113. return None
  114. if license_enabled:
  115. if 'licenses' not in emoji['cache_keys']:
  116. raise RuntimeError(f"Emoji '{emoji['short']}' does not have a "
  117. "cache key set for licenses.")
  118. license_type = util.get_license_type_for_format(f)
  119. if license_type:
  120. if license_type in emoji['cache_keys']['licenses']:
  121. cache_key = emoji['cache_keys']['licenses'][license_type]
  122. else:
  123. raise RuntimeError(f"License type '{license_type}' cache "
  124. f"key not present for emoji "
  125. f"'{emoji['short']}'.")
  126. else:
  127. cache_key = emoji['cache_keys']['base']
  128. if cache_key:
  129. dir_path = self.build_cache_dir_by_format(f)
  130. return os.path.join(dir_path, cache_key)
  131. else:
  132. return None
  133. def build_cache_dir_by_format(self, f):
  134. """
  135. Checks if the build cache directory for the given format exists,
  136. attempting to create it if it doesn't, and returns its path.
  137. """
  138. if not self.cache_dir:
  139. raise RuntimeError("cache dir not set")
  140. dir_path = os.path.join(self.cache_dir, f)
  141. if os.path.isdir(dir_path):
  142. # Return immediately if it exists
  143. return dir_path
  144. if os.path.exists(dir_path): # Exists but is not directory
  145. raise RuntimeError("cache path '{}' exists, but it is not a "
  146. "directory".format(dir_path))
  147. # Create directory
  148. try:
  149. os.mkdir(dir_path)
  150. except OSError as exc:
  151. raise RuntimeError("Cannot create build cache directory "
  152. "'{}'".format(dir_path)) from exc
  153. return dir_path
  154. def get_cache(self, emoji, f, license_enabled):
  155. """
  156. Get the path to an existing emoji in a given format `f` that is in
  157. cache, or `None` if the cache file does not exist.
  158. If `license_enabled` is `False`, the cache file for a non-licensed
  159. export of the format `f` is looked up.
  160. If `license_enabled` is `True`, the cache file for a licensed export of
  161. the format `f` is looked up; if `f` does not support a license, `None`
  162. is returned.
  163. """
  164. cache_file = self.build_emoji_cache_path(emoji, f, license_enabled)
  165. if cache_file and os.path.exists(cache_file):
  166. return cache_file
  167. return None
  168. def save_to_cache(self, emoji, f, export_path, license_enabled):
  169. """
  170. Copy an exported path to the cache directory.
  171. If `license_enabled` is `False`, the `export_path` will be copied to a
  172. cache key for a base export of the format.
  173. If `license_enabled` is `True`, the `export_path` will be copied to a
  174. cache key for a licensed form of the format `f`; if the format `f` does
  175. not support a license but `license_enabled` is set, `False` is
  176. returned.
  177. """
  178. if not os.path.exists(export_path):
  179. raise RuntimeError("Could not find exported emoji '{}' at "
  180. "'{}'".format(emoji['short'], export_path))
  181. cache_file = self.build_emoji_cache_path(emoji, f, license_enabled)
  182. if cache_file is None:
  183. return False
  184. try:
  185. shutil.copy(export_path, cache_file)
  186. except OSError as exc:
  187. raise RuntimeError("Unable to save '{}' to cache ('{}'): "
  188. "{}.".format(emoji['short'], cache_file,
  189. str(exc)))
  190. return True
  191. def load_from_cache(self, emoji, f, export_path, license_enabled):
  192. """
  193. Copy an emoji from cache to its final path, `export_path`.
  194. If `license_enabled` is `False`, the cache for a non-licensed format
  195. `f` is looked up, and copied if it exists.
  196. If `license_enabled` is `True`, the cache for a licensed format `f` is
  197. looked up and copied if it exists; if `f` does not support a license,
  198. `False` is returned.
  199. """
  200. if not self.cache_dir:
  201. return False
  202. cache_file = self.get_cache(emoji, f, license_enabled)
  203. if not cache_file:
  204. return False
  205. try:
  206. shutil.copy(cache_file, export_path)
  207. except OSError as exc:
  208. raise RuntimeError("Unable to retrieve '{}' from cache ('{}'): "
  209. "{}".format(emoji['short'], cache_file,
  210. str(exc)))
  211. return True
  212. @classmethod
  213. def filter_cacheable_formats(cls, fs, license_enabled):
  214. """
  215. Obtain the formats from `fs` that are cacheable given the status of the
  216. license.
  217. """
  218. cacheable_fs = fs
  219. if not license_enabled: # Exports
  220. cacheable_fs = tuple(set(fs) - cls.skip_export_cache_formats)
  221. return cacheable_fs