collectstatic.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. from __future__ import unicode_literals
  2. import os
  3. from collections import OrderedDict
  4. from optparse import make_option
  5. from django.core.files.storage import FileSystemStorage
  6. from django.core.management.base import CommandError, NoArgsCommand
  7. from django.utils.encoding import smart_text
  8. from django.utils.six.moves import input
  9. from django.contrib.staticfiles.finders import get_finders
  10. from django.contrib.staticfiles.storage import staticfiles_storage
  11. class Command(NoArgsCommand):
  12. """
  13. Command that allows to copy or symlink static files from different
  14. locations to the settings.STATIC_ROOT.
  15. """
  16. option_list = NoArgsCommand.option_list + (
  17. make_option('--noinput',
  18. action='store_false', dest='interactive', default=True,
  19. help="Do NOT prompt the user for input of any kind."),
  20. make_option('--no-post-process',
  21. action='store_false', dest='post_process', default=True,
  22. help="Do NOT post process collected files."),
  23. make_option('-i', '--ignore', action='append', default=[],
  24. dest='ignore_patterns', metavar='PATTERN',
  25. help="Ignore files or directories matching this glob-style "
  26. "pattern. Use multiple times to ignore more."),
  27. make_option('-n', '--dry-run',
  28. action='store_true', dest='dry_run', default=False,
  29. help="Do everything except modify the filesystem."),
  30. make_option('-c', '--clear',
  31. action='store_true', dest='clear', default=False,
  32. help="Clear the existing files using the storage "
  33. "before trying to copy or link the original file."),
  34. make_option('-l', '--link',
  35. action='store_true', dest='link', default=False,
  36. help="Create a symbolic link to each file instead of copying."),
  37. make_option('--no-default-ignore', action='store_false',
  38. dest='use_default_ignore_patterns', default=True,
  39. help="Don't ignore the common private glob-style patterns 'CVS', "
  40. "'.*' and '*~'."),
  41. )
  42. help = "Collect static files in a single location."
  43. requires_system_checks = False
  44. def __init__(self, *args, **kwargs):
  45. super(NoArgsCommand, self).__init__(*args, **kwargs)
  46. self.copied_files = []
  47. self.symlinked_files = []
  48. self.unmodified_files = []
  49. self.post_processed_files = []
  50. self.storage = staticfiles_storage
  51. try:
  52. self.storage.path('')
  53. except NotImplementedError:
  54. self.local = False
  55. else:
  56. self.local = True
  57. def set_options(self, **options):
  58. """
  59. Set instance variables based on an options dict
  60. """
  61. self.interactive = options['interactive']
  62. self.verbosity = int(options.get('verbosity', 1))
  63. self.symlink = options['link']
  64. self.clear = options['clear']
  65. self.dry_run = options['dry_run']
  66. ignore_patterns = options['ignore_patterns']
  67. if options['use_default_ignore_patterns']:
  68. ignore_patterns += ['CVS', '.*', '*~']
  69. self.ignore_patterns = list(set(ignore_patterns))
  70. self.post_process = options['post_process']
  71. def collect(self):
  72. """
  73. Perform the bulk of the work of collectstatic.
  74. Split off from handle_noargs() to facilitate testing.
  75. """
  76. if self.symlink and not self.local:
  77. raise CommandError("Can't symlink to a remote destination.")
  78. if self.clear:
  79. self.clear_dir('')
  80. if self.symlink:
  81. handler = self.link_file
  82. else:
  83. handler = self.copy_file
  84. found_files = OrderedDict()
  85. for finder in get_finders():
  86. for path, storage in finder.list(self.ignore_patterns):
  87. # Prefix the relative path if the source storage contains it
  88. if getattr(storage, 'prefix', None):
  89. prefixed_path = os.path.join(storage.prefix, path)
  90. else:
  91. prefixed_path = path
  92. if prefixed_path not in found_files:
  93. found_files[prefixed_path] = (storage, path)
  94. handler(path, prefixed_path, storage)
  95. # Here we check if the storage backend has a post_process
  96. # method and pass it the list of modified files.
  97. if self.post_process and hasattr(self.storage, 'post_process'):
  98. processor = self.storage.post_process(found_files,
  99. dry_run=self.dry_run)
  100. for original_path, processed_path, processed in processor:
  101. if isinstance(processed, Exception):
  102. self.stderr.write("Post-processing '%s' failed!" % original_path)
  103. # Add a blank line before the traceback, otherwise it's
  104. # too easy to miss the relevant part of the error message.
  105. self.stderr.write("")
  106. raise processed
  107. if processed:
  108. self.log("Post-processed '%s' as '%s'" %
  109. (original_path, processed_path), level=1)
  110. self.post_processed_files.append(original_path)
  111. else:
  112. self.log("Skipped post-processing '%s'" % original_path)
  113. return {
  114. 'modified': self.copied_files + self.symlinked_files,
  115. 'unmodified': self.unmodified_files,
  116. 'post_processed': self.post_processed_files,
  117. }
  118. def handle_noargs(self, **options):
  119. self.set_options(**options)
  120. message = ['\n']
  121. if self.dry_run:
  122. message.append(
  123. 'You have activated the --dry-run option so no files will be modified.\n\n'
  124. )
  125. message.append(
  126. 'You have requested to collect static files at the destination\n'
  127. 'location as specified in your settings'
  128. )
  129. if self.is_local_storage() and self.storage.location:
  130. destination_path = self.storage.location
  131. message.append(':\n\n %s\n\n' % destination_path)
  132. else:
  133. destination_path = None
  134. message.append('.\n\n')
  135. if self.clear:
  136. message.append('This will DELETE EXISTING FILES!\n')
  137. else:
  138. message.append('This will overwrite existing files!\n')
  139. message.append(
  140. 'Are you sure you want to do this?\n\n'
  141. "Type 'yes' to continue, or 'no' to cancel: "
  142. )
  143. if self.interactive and input(''.join(message)) != 'yes':
  144. raise CommandError("Collecting static files cancelled.")
  145. collected = self.collect()
  146. modified_count = len(collected['modified'])
  147. unmodified_count = len(collected['unmodified'])
  148. post_processed_count = len(collected['post_processed'])
  149. if self.verbosity >= 1:
  150. template = ("\n%(modified_count)s %(identifier)s %(action)s"
  151. "%(destination)s%(unmodified)s%(post_processed)s.\n")
  152. summary = template % {
  153. 'modified_count': modified_count,
  154. 'identifier': 'static file' + ('' if modified_count == 1 else 's'),
  155. 'action': 'symlinked' if self.symlink else 'copied',
  156. 'destination': (" to '%s'" % destination_path if destination_path else ''),
  157. 'unmodified': (', %s unmodified' % unmodified_count if collected['unmodified'] else ''),
  158. 'post_processed': (collected['post_processed'] and
  159. ', %s post-processed'
  160. % post_processed_count or ''),
  161. }
  162. self.stdout.write(summary)
  163. def log(self, msg, level=2):
  164. """
  165. Small log helper
  166. """
  167. if self.verbosity >= level:
  168. self.stdout.write(msg)
  169. def is_local_storage(self):
  170. return isinstance(self.storage, FileSystemStorage)
  171. def clear_dir(self, path):
  172. """
  173. Deletes the given relative path using the destination storage backend.
  174. """
  175. dirs, files = self.storage.listdir(path)
  176. for f in files:
  177. fpath = os.path.join(path, f)
  178. if self.dry_run:
  179. self.log("Pretending to delete '%s'" %
  180. smart_text(fpath), level=1)
  181. else:
  182. self.log("Deleting '%s'" % smart_text(fpath), level=1)
  183. self.storage.delete(fpath)
  184. for d in dirs:
  185. self.clear_dir(os.path.join(path, d))
  186. def delete_file(self, path, prefixed_path, source_storage):
  187. """
  188. Checks if the target file should be deleted if it already exists
  189. """
  190. if self.storage.exists(prefixed_path):
  191. try:
  192. # When was the target file modified last time?
  193. target_last_modified = \
  194. self.storage.modified_time(prefixed_path)
  195. except (OSError, NotImplementedError, AttributeError):
  196. # The storage doesn't support ``modified_time`` or failed
  197. pass
  198. else:
  199. try:
  200. # When was the source file modified last time?
  201. source_last_modified = source_storage.modified_time(path)
  202. except (OSError, NotImplementedError, AttributeError):
  203. pass
  204. else:
  205. # The full path of the target file
  206. if self.local:
  207. full_path = self.storage.path(prefixed_path)
  208. else:
  209. full_path = None
  210. # Skip the file if the source file is younger
  211. # Avoid sub-second precision (see #14665, #19540)
  212. if (target_last_modified.replace(microsecond=0)
  213. >= source_last_modified.replace(microsecond=0)):
  214. if not ((self.symlink and full_path
  215. and not os.path.islink(full_path)) or
  216. (not self.symlink and full_path
  217. and os.path.islink(full_path))):
  218. if prefixed_path not in self.unmodified_files:
  219. self.unmodified_files.append(prefixed_path)
  220. self.log("Skipping '%s' (not modified)" % path)
  221. return False
  222. # Then delete the existing file if really needed
  223. if self.dry_run:
  224. self.log("Pretending to delete '%s'" % path)
  225. else:
  226. self.log("Deleting '%s'" % path)
  227. self.storage.delete(prefixed_path)
  228. return True
  229. def link_file(self, path, prefixed_path, source_storage):
  230. """
  231. Attempt to link ``path``
  232. """
  233. # Skip this file if it was already copied earlier
  234. if prefixed_path in self.symlinked_files:
  235. return self.log("Skipping '%s' (already linked earlier)" % path)
  236. # Delete the target file if needed or break
  237. if not self.delete_file(path, prefixed_path, source_storage):
  238. return
  239. # The full path of the source file
  240. source_path = source_storage.path(path)
  241. # Finally link the file
  242. if self.dry_run:
  243. self.log("Pretending to link '%s'" % source_path, level=1)
  244. else:
  245. self.log("Linking '%s'" % source_path, level=1)
  246. full_path = self.storage.path(prefixed_path)
  247. try:
  248. os.makedirs(os.path.dirname(full_path))
  249. except OSError:
  250. pass
  251. try:
  252. if os.path.lexists(full_path):
  253. os.unlink(full_path)
  254. os.symlink(source_path, full_path)
  255. except AttributeError:
  256. import platform
  257. raise CommandError("Symlinking is not supported by Python %s." %
  258. platform.python_version())
  259. except NotImplementedError:
  260. import platform
  261. raise CommandError("Symlinking is not supported in this "
  262. "platform (%s)." % platform.platform())
  263. except OSError as e:
  264. raise CommandError(e)
  265. if prefixed_path not in self.symlinked_files:
  266. self.symlinked_files.append(prefixed_path)
  267. def copy_file(self, path, prefixed_path, source_storage):
  268. """
  269. Attempt to copy ``path`` with storage
  270. """
  271. # Skip this file if it was already copied earlier
  272. if prefixed_path in self.copied_files:
  273. return self.log("Skipping '%s' (already copied earlier)" % path)
  274. # Delete the target file if needed or break
  275. if not self.delete_file(path, prefixed_path, source_storage):
  276. return
  277. # The full path of the source file
  278. source_path = source_storage.path(path)
  279. # Finally start copying
  280. if self.dry_run:
  281. self.log("Pretending to copy '%s'" % source_path, level=1)
  282. else:
  283. self.log("Copying '%s'" % source_path, level=1)
  284. with source_storage.open(path) as source_file:
  285. self.storage.save(prefixed_path, source_file)
  286. if prefixed_path not in self.copied_files:
  287. self.copied_files.append(prefixed_path)