Development of an internal social media platform with personalised dashboards for students
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

collectstatic.py 15KB

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