PageRenderTime 56ms CodeModel.GetById 27ms RepoModel.GetById 0ms app.codeStats 0ms

/django/contrib/staticfiles/storage.py

https://github.com/andnils/django
Python | 389 lines | 308 code | 27 blank | 54 comment | 39 complexity | 0fe9c4fce0c604627ac3d9289a550487 MD5 | raw file
Possible License(s): BSD-3-Clause
  1. from __future__ import unicode_literals
  2. from collections import OrderedDict
  3. import hashlib
  4. import os
  5. import posixpath
  6. import re
  7. import json
  8. from django.conf import settings
  9. from django.core.cache import (caches, InvalidCacheBackendError,
  10. cache as default_cache)
  11. from django.core.exceptions import ImproperlyConfigured
  12. from django.core.files.base import ContentFile
  13. from django.core.files.storage import FileSystemStorage, get_storage_class
  14. from django.utils.encoding import force_bytes, force_text
  15. from django.utils.functional import LazyObject
  16. from django.utils.six.moves.urllib.parse import unquote, urlsplit, urlunsplit, urldefrag
  17. from django.contrib.staticfiles.utils import check_settings, matches_patterns
  18. class StaticFilesStorage(FileSystemStorage):
  19. """
  20. Standard file system storage for static files.
  21. The defaults for ``location`` and ``base_url`` are
  22. ``STATIC_ROOT`` and ``STATIC_URL``.
  23. """
  24. def __init__(self, location=None, base_url=None, *args, **kwargs):
  25. if location is None:
  26. location = settings.STATIC_ROOT
  27. if base_url is None:
  28. base_url = settings.STATIC_URL
  29. check_settings(base_url)
  30. super(StaticFilesStorage, self).__init__(location, base_url,
  31. *args, **kwargs)
  32. # FileSystemStorage fallbacks to MEDIA_ROOT when location
  33. # is empty, so we restore the empty value.
  34. if not location:
  35. self.base_location = None
  36. self.location = None
  37. def path(self, name):
  38. if not self.location:
  39. raise ImproperlyConfigured("You're using the staticfiles app "
  40. "without having set the STATIC_ROOT "
  41. "setting to a filesystem path.")
  42. return super(StaticFilesStorage, self).path(name)
  43. class HashedFilesMixin(object):
  44. default_template = """url("%s")"""
  45. patterns = (
  46. ("*.css", (
  47. r"""(url\(['"]{0,1}\s*(.*?)["']{0,1}\))""",
  48. (r"""(@import\s*["']\s*(.*?)["'])""", """@import url("%s")"""),
  49. )),
  50. )
  51. def __init__(self, *args, **kwargs):
  52. super(HashedFilesMixin, self).__init__(*args, **kwargs)
  53. self._patterns = OrderedDict()
  54. self.hashed_files = {}
  55. for extension, patterns in self.patterns:
  56. for pattern in patterns:
  57. if isinstance(pattern, (tuple, list)):
  58. pattern, template = pattern
  59. else:
  60. template = self.default_template
  61. compiled = re.compile(pattern, re.IGNORECASE)
  62. self._patterns.setdefault(extension, []).append((compiled, template))
  63. def file_hash(self, name, content=None):
  64. """
  65. Retuns a hash of the file with the given name and optional content.
  66. """
  67. if content is None:
  68. return None
  69. md5 = hashlib.md5()
  70. for chunk in content.chunks():
  71. md5.update(chunk)
  72. return md5.hexdigest()[:12]
  73. def hashed_name(self, name, content=None):
  74. parsed_name = urlsplit(unquote(name))
  75. clean_name = parsed_name.path.strip()
  76. opened = False
  77. if content is None:
  78. if not self.exists(clean_name):
  79. raise ValueError("The file '%s' could not be found with %r." %
  80. (clean_name, self))
  81. try:
  82. content = self.open(clean_name)
  83. except IOError:
  84. # Handle directory paths and fragments
  85. return name
  86. opened = True
  87. try:
  88. file_hash = self.file_hash(clean_name, content)
  89. finally:
  90. if opened:
  91. content.close()
  92. path, filename = os.path.split(clean_name)
  93. root, ext = os.path.splitext(filename)
  94. if file_hash is not None:
  95. file_hash = ".%s" % file_hash
  96. hashed_name = os.path.join(path, "%s%s%s" %
  97. (root, file_hash, ext))
  98. unparsed_name = list(parsed_name)
  99. unparsed_name[2] = hashed_name
  100. # Special casing for a @font-face hack, like url(myfont.eot?#iefix")
  101. # http://www.fontspring.com/blog/the-new-bulletproof-font-face-syntax
  102. if '?#' in name and not unparsed_name[3]:
  103. unparsed_name[2] += '?'
  104. return urlunsplit(unparsed_name)
  105. def url(self, name, force=False):
  106. """
  107. Returns the real URL in DEBUG mode.
  108. """
  109. if settings.DEBUG and not force:
  110. hashed_name, fragment = name, ''
  111. else:
  112. clean_name, fragment = urldefrag(name)
  113. if urlsplit(clean_name).path.endswith('/'): # don't hash paths
  114. hashed_name = name
  115. else:
  116. hashed_name = self.stored_name(clean_name)
  117. final_url = super(HashedFilesMixin, self).url(hashed_name)
  118. # Special casing for a @font-face hack, like url(myfont.eot?#iefix")
  119. # http://www.fontspring.com/blog/the-new-bulletproof-font-face-syntax
  120. query_fragment = '?#' in name # [sic!]
  121. if fragment or query_fragment:
  122. urlparts = list(urlsplit(final_url))
  123. if fragment and not urlparts[4]:
  124. urlparts[4] = fragment
  125. if query_fragment and not urlparts[3]:
  126. urlparts[2] += '?'
  127. final_url = urlunsplit(urlparts)
  128. return unquote(final_url)
  129. def url_converter(self, name, template=None):
  130. """
  131. Returns the custom URL converter for the given file name.
  132. """
  133. if template is None:
  134. template = self.default_template
  135. def converter(matchobj):
  136. """
  137. Converts the matched URL depending on the parent level (`..`)
  138. and returns the normalized and hashed URL using the url method
  139. of the storage.
  140. """
  141. matched, url = matchobj.groups()
  142. # Completely ignore http(s) prefixed URLs,
  143. # fragments and data-uri URLs
  144. if url.startswith(('#', 'http:', 'https:', 'data:', '//')):
  145. return matched
  146. name_parts = name.split(os.sep)
  147. # Using posix normpath here to remove duplicates
  148. url = posixpath.normpath(url)
  149. url_parts = url.split('/')
  150. parent_level, sub_level = url.count('..'), url.count('/')
  151. if url.startswith('/'):
  152. sub_level -= 1
  153. url_parts = url_parts[1:]
  154. if parent_level or not url.startswith('/'):
  155. start, end = parent_level + 1, parent_level
  156. else:
  157. if sub_level:
  158. if sub_level == 1:
  159. parent_level -= 1
  160. start, end = parent_level, 1
  161. else:
  162. start, end = 1, sub_level - 1
  163. joined_result = '/'.join(name_parts[:-start] + url_parts[end:])
  164. hashed_url = self.url(unquote(joined_result), force=True)
  165. file_name = hashed_url.split('/')[-1:]
  166. relative_url = '/'.join(url.split('/')[:-1] + file_name)
  167. # Return the hashed version to the file
  168. return template % unquote(relative_url)
  169. return converter
  170. def post_process(self, paths, dry_run=False, **options):
  171. """
  172. Post process the given OrderedDict of files (called from collectstatic).
  173. Processing is actually two separate operations:
  174. 1. renaming files to include a hash of their content for cache-busting,
  175. and copying those files to the target storage.
  176. 2. adjusting files which contain references to other files so they
  177. refer to the cache-busting filenames.
  178. If either of these are performed on a file, then that file is considered
  179. post-processed.
  180. """
  181. # don't even dare to process the files if we're in dry run mode
  182. if dry_run:
  183. return
  184. # where to store the new paths
  185. hashed_files = OrderedDict()
  186. # build a list of adjustable files
  187. matches = lambda path: matches_patterns(path, self._patterns.keys())
  188. adjustable_paths = [path for path in paths if matches(path)]
  189. # then sort the files by the directory level
  190. path_level = lambda name: len(name.split(os.sep))
  191. for name in sorted(paths.keys(), key=path_level, reverse=True):
  192. # use the original, local file, not the copied-but-unprocessed
  193. # file, which might be somewhere far away, like S3
  194. storage, path = paths[name]
  195. with storage.open(path) as original_file:
  196. # generate the hash with the original content, even for
  197. # adjustable files.
  198. hashed_name = self.hashed_name(name, original_file)
  199. # then get the original's file content..
  200. if hasattr(original_file, 'seek'):
  201. original_file.seek(0)
  202. hashed_file_exists = self.exists(hashed_name)
  203. processed = False
  204. # ..to apply each replacement pattern to the content
  205. if name in adjustable_paths:
  206. content = original_file.read().decode(settings.FILE_CHARSET)
  207. for patterns in self._patterns.values():
  208. for pattern, template in patterns:
  209. converter = self.url_converter(name, template)
  210. try:
  211. content = pattern.sub(converter, content)
  212. except ValueError as exc:
  213. yield name, None, exc
  214. if hashed_file_exists:
  215. self.delete(hashed_name)
  216. # then save the processed result
  217. content_file = ContentFile(force_bytes(content))
  218. saved_name = self._save(hashed_name, content_file)
  219. hashed_name = force_text(self.clean_name(saved_name))
  220. processed = True
  221. else:
  222. # or handle the case in which neither processing nor
  223. # a change to the original file happened
  224. if not hashed_file_exists:
  225. processed = True
  226. saved_name = self._save(hashed_name, original_file)
  227. hashed_name = force_text(self.clean_name(saved_name))
  228. # and then set the cache accordingly
  229. hashed_files[self.hash_key(name)] = hashed_name
  230. yield name, hashed_name, processed
  231. # Finally store the processed paths
  232. self.hashed_files.update(hashed_files)
  233. def clean_name(self, name):
  234. return name.replace('\\', '/')
  235. def hash_key(self, name):
  236. return name
  237. def stored_name(self, name):
  238. hash_key = self.hash_key(name)
  239. cache_name = self.hashed_files.get(hash_key)
  240. if cache_name is None:
  241. cache_name = self.clean_name(self.hashed_name(name))
  242. # store the hashed name if there was a miss, e.g.
  243. # when the files are still processed
  244. self.hashed_files[hash_key] = cache_name
  245. return cache_name
  246. class ManifestFilesMixin(HashedFilesMixin):
  247. manifest_version = '1.0' # the manifest format standard
  248. manifest_name = 'staticfiles.json'
  249. def __init__(self, *args, **kwargs):
  250. super(ManifestFilesMixin, self).__init__(*args, **kwargs)
  251. self.hashed_files = self.load_manifest()
  252. def read_manifest(self):
  253. try:
  254. with self.open(self.manifest_name) as manifest:
  255. return manifest.read().decode('utf-8')
  256. except IOError:
  257. return None
  258. def load_manifest(self):
  259. content = self.read_manifest()
  260. if content is None:
  261. return OrderedDict()
  262. try:
  263. stored = json.loads(content, object_pairs_hook=OrderedDict)
  264. except ValueError:
  265. pass
  266. else:
  267. version = stored.get('version', None)
  268. if version == '1.0':
  269. return stored.get('paths', OrderedDict())
  270. raise ValueError("Couldn't load manifest '%s' (version %s)" %
  271. (self.manifest_name, self.manifest_version))
  272. def post_process(self, *args, **kwargs):
  273. all_post_processed = super(ManifestFilesMixin,
  274. self).post_process(*args, **kwargs)
  275. for post_processed in all_post_processed:
  276. yield post_processed
  277. payload = {'paths': self.hashed_files, 'version': self.manifest_version}
  278. if self.exists(self.manifest_name):
  279. self.delete(self.manifest_name)
  280. contents = json.dumps(payload).encode('utf-8')
  281. self._save(self.manifest_name, ContentFile(contents))
  282. class _MappingCache(object):
  283. """
  284. A small dict-like wrapper for a given cache backend instance.
  285. """
  286. def __init__(self, cache):
  287. self.cache = cache
  288. def __setitem__(self, key, value):
  289. self.cache.set(key, value)
  290. def __getitem__(self, key):
  291. value = self.cache.get(key, None)
  292. if value is None:
  293. raise KeyError("Couldn't find a file name '%s'" % key)
  294. return value
  295. def clear(self):
  296. self.cache.clear()
  297. def update(self, data):
  298. self.cache.set_many(data)
  299. def get(self, key, default=None):
  300. try:
  301. return self[key]
  302. except KeyError:
  303. return default
  304. class CachedFilesMixin(HashedFilesMixin):
  305. def __init__(self, *args, **kwargs):
  306. super(CachedFilesMixin, self).__init__(*args, **kwargs)
  307. try:
  308. self.hashed_files = _MappingCache(caches['staticfiles'])
  309. except InvalidCacheBackendError:
  310. # Use the default backend
  311. self.hashed_files = _MappingCache(default_cache)
  312. def hash_key(self, name):
  313. key = hashlib.md5(force_bytes(self.clean_name(name))).hexdigest()
  314. return 'staticfiles:%s' % key
  315. class CachedStaticFilesStorage(CachedFilesMixin, StaticFilesStorage):
  316. """
  317. A static file system storage backend which also saves
  318. hashed copies of the files it saves.
  319. """
  320. pass
  321. class ManifestStaticFilesStorage(ManifestFilesMixin, StaticFilesStorage):
  322. """
  323. A static file system storage backend which also saves
  324. hashed copies of the files it saves.
  325. """
  326. pass
  327. class ConfiguredStorage(LazyObject):
  328. def _setup(self):
  329. self._wrapped = get_storage_class(settings.STATICFILES_STORAGE)()
  330. staticfiles_storage = ConfiguredStorage()