Fixed #16161 -- Added `--clear` option to `collectstatic` management command to be able to explicitly clear the files stored in the destination storage before collecting.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@16509 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Jannis Leidel 2011-07-04 21:34:29 +00:00
parent c2a48110d4
commit 94a38dfd0e
4 changed files with 114 additions and 53 deletions

View File

@ -1,12 +1,11 @@
import os import os
import sys import sys
import shutil
from optparse import make_option from optparse import make_option
from django.conf import settings from django.conf import settings
from django.core.files.storage import get_storage_class from django.core.files.storage import FileSystemStorage, get_storage_class
from django.core.management.base import CommandError, NoArgsCommand from django.core.management.base import CommandError, NoArgsCommand
from django.utils.encoding import smart_str from django.utils.encoding import smart_str, smart_unicode
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
@ -24,6 +23,9 @@ class Command(NoArgsCommand):
"pattern. Use multiple times to ignore more."), "pattern. Use multiple times to ignore more."),
make_option('-n', '--dry-run', action='store_true', dest='dry_run', make_option('-n', '--dry-run', action='store_true', dest='dry_run',
default=False, help="Do everything except modify the filesystem."), default=False, help="Do everything except modify the filesystem."),
make_option('-c', '--clear', action='store_true', dest='clear',
default=False, help="Clear the existing files using the storage "
"before trying to copy or link the original file."),
make_option('-l', '--link', action='store_true', dest='link', make_option('-l', '--link', action='store_true', dest='link',
default=False, help="Create a symbolic link to each file instead of copying."), default=False, help="Create a symbolic link to each file instead of copying."),
make_option('--no-default-ignore', action='store_false', make_option('--no-default-ignore', action='store_false',
@ -49,14 +51,17 @@ class Command(NoArgsCommand):
os.stat_float_times(False) os.stat_float_times(False)
def handle_noargs(self, **options): def handle_noargs(self, **options):
symlink = options['link'] self.clear = options['clear']
self.dry_run = options['dry_run']
ignore_patterns = options['ignore_patterns'] ignore_patterns = options['ignore_patterns']
if options['use_default_ignore_patterns']: if options['use_default_ignore_patterns']:
ignore_patterns += ['CVS', '.*', '*~'] ignore_patterns += ['CVS', '.*', '*~']
ignore_patterns = list(set(ignore_patterns)) self.ignore_patterns = list(set(ignore_patterns))
self.interactive = options['interactive']
self.symlink = options['link']
self.verbosity = int(options.get('verbosity', 1)) self.verbosity = int(options.get('verbosity', 1))
if symlink: if self.symlink:
if sys.platform == 'win32': if sys.platform == 'win32':
raise CommandError("Symlinking is not supported by this " raise CommandError("Symlinking is not supported by this "
"platform (%s)." % sys.platform) "platform (%s)." % sys.platform)
@ -64,39 +69,58 @@ class Command(NoArgsCommand):
raise CommandError("Can't symlink to a remote destination.") raise CommandError("Can't symlink to a remote destination.")
# Warn before doing anything more. # Warn before doing anything more.
if options.get('interactive'): if (isinstance(self.storage, FileSystemStorage) and
self.storage.location):
destination_path = self.storage.location
destination_display = ':\n\n %s' % destination_path
else:
destination_path = None
destination_display = '.'
if self.clear:
clear_display = 'This will DELETE EXISTING FILES!'
else:
clear_display = 'This will overwrite existing files!'
if self.interactive:
confirm = raw_input(u""" confirm = raw_input(u"""
You have requested to collect static files at the destination You have requested to collect static files at the destination
location as specified in your settings file. location as specified in your settings%s
This will overwrite existing files. %s
Are you sure you want to do this? Are you sure you want to do this?
Type 'yes' to continue, or 'no' to cancel: """) Type 'yes' to continue, or 'no' to cancel: """
% (destination_display, clear_display))
if confirm != 'yes': if confirm != 'yes':
raise CommandError("Collecting static files cancelled.") raise CommandError("Collecting static files cancelled.")
if self.clear:
self.clear_dir('')
handler = {
True: self.link_file,
False: self.copy_file
}[self.symlink]
for finder in finders.get_finders(): for finder in finders.get_finders():
for path, storage in finder.list(ignore_patterns): for path, storage in finder.list(self.ignore_patterns):
# Prefix the relative path if the source storage contains it # Prefix the relative path if the source storage contains it
if getattr(storage, 'prefix', None): if getattr(storage, 'prefix', None):
prefixed_path = os.path.join(storage.prefix, path) path = os.path.join(storage.prefix, path)
else: handler(path, path, storage)
prefixed_path = path
if symlink:
self.link_file(path, prefixed_path, storage, **options)
else:
self.copy_file(path, prefixed_path, storage, **options)
actual_count = len(self.copied_files) + len(self.symlinked_files) actual_count = len(self.copied_files) + len(self.symlinked_files)
unmodified_count = len(self.unmodified_files) unmodified_count = len(self.unmodified_files)
if self.verbosity >= 1: if self.verbosity >= 1:
self.stdout.write(smart_str(u"\n%s static file%s %s to '%s'%s.\n" self.stdout.write(smart_str(u"\n%s static file%s %s %s%s.\n"
% (actual_count, actual_count != 1 and 's' or '', % (actual_count,
symlink and 'symlinked' or 'copied', actual_count != 1 and 's' or '',
settings.STATIC_ROOT, self.symlink and 'symlinked' or 'copied',
destination_path and "to '%s'"
% destination_path or '',
unmodified_count and ' (%s unmodified)' unmodified_count and ' (%s unmodified)'
% unmodified_count or ''))) % unmodified_count or '')))
def log(self, msg, level=2): def log(self, msg, level=2):
""" """
@ -108,9 +132,23 @@ Type 'yes' to continue, or 'no' to cancel: """)
if self.verbosity >= level: if self.verbosity >= level:
self.stdout.write(msg) self.stdout.write(msg)
def delete_file(self, path, prefixed_path, source_storage, **options): def clear_dir(self, path):
"""
Deletes the given relative path using the destinatin storage backend.
"""
dirs, files = self.storage.listdir(path)
for f in files:
fpath = os.path.join(path, f)
if self.dry_run:
self.log(u"Pretending to delete '%s'" % smart_unicode(fpath), level=1)
else:
self.log(u"Deleting '%s'" % smart_unicode(fpath), level=1)
self.storage.delete(fpath)
for d in dirs:
self.clear_dir(os.path.join(path, d))
def delete_file(self, path, prefixed_path, source_storage):
# Whether we are in symlink mode # Whether we are in symlink mode
symlink = options['link']
# Checks if the target file should be deleted if it already exists # Checks if the target file should be deleted if it already exists
if self.storage.exists(prefixed_path): if self.storage.exists(prefixed_path):
try: try:
@ -133,21 +171,21 @@ Type 'yes' to continue, or 'no' to cancel: """)
full_path = None full_path = None
# Skip the file if the source file is younger # Skip the file if the source file is younger
if target_last_modified >= source_last_modified: if target_last_modified >= source_last_modified:
if not ((symlink and full_path and not os.path.islink(full_path)) or if not ((self.symlink and full_path and not os.path.islink(full_path)) or
(not symlink and full_path and os.path.islink(full_path))): (not self.symlink and full_path and os.path.islink(full_path))):
if prefixed_path not in self.unmodified_files: if prefixed_path not in self.unmodified_files:
self.unmodified_files.append(prefixed_path) self.unmodified_files.append(prefixed_path)
self.log(u"Skipping '%s' (not modified)" % path) self.log(u"Skipping '%s' (not modified)" % path)
return False return False
# Then delete the existing file if really needed # Then delete the existing file if really needed
if options['dry_run']: if self.dry_run:
self.log(u"Pretending to delete '%s'" % path) self.log(u"Pretending to delete '%s'" % path)
else: else:
self.log(u"Deleting '%s'" % path) self.log(u"Deleting '%s'" % path)
self.storage.delete(prefixed_path) self.storage.delete(prefixed_path)
return True return True
def link_file(self, path, prefixed_path, source_storage, **options): def link_file(self, path, prefixed_path, source_storage):
""" """
Attempt to link ``path`` Attempt to link ``path``
""" """
@ -155,12 +193,12 @@ Type 'yes' to continue, or 'no' to cancel: """)
if prefixed_path in self.symlinked_files: if prefixed_path in self.symlinked_files:
return self.log(u"Skipping '%s' (already linked earlier)" % path) return self.log(u"Skipping '%s' (already linked earlier)" % path)
# Delete the target file if needed or break # Delete the target file if needed or break
if not self.delete_file(path, prefixed_path, source_storage, **options): if not self.delete_file(path, prefixed_path, source_storage):
return return
# The full path of the source file # The full path of the source file
source_path = source_storage.path(path) source_path = source_storage.path(path)
# Finally link the file # Finally link the file
if options['dry_run']: if self.dry_run:
self.log(u"Pretending to link '%s'" % source_path, level=1) self.log(u"Pretending to link '%s'" % source_path, level=1)
else: else:
self.log(u"Linking '%s'" % source_path, level=1) self.log(u"Linking '%s'" % source_path, level=1)
@ -173,7 +211,7 @@ Type 'yes' to continue, or 'no' to cancel: """)
if prefixed_path not in self.symlinked_files: if prefixed_path not in self.symlinked_files:
self.symlinked_files.append(prefixed_path) self.symlinked_files.append(prefixed_path)
def copy_file(self, path, prefixed_path, source_storage, **options): def copy_file(self, path, prefixed_path, source_storage):
""" """
Attempt to copy ``path`` with storage Attempt to copy ``path`` with storage
""" """
@ -181,12 +219,12 @@ Type 'yes' to continue, or 'no' to cancel: """)
if prefixed_path in self.copied_files: if prefixed_path in self.copied_files:
return self.log(u"Skipping '%s' (already copied earlier)" % path) return self.log(u"Skipping '%s' (already copied earlier)" % path)
# Delete the target file if needed or break # Delete the target file if needed or break
if not self.delete_file(path, prefixed_path, source_storage, **options): if not self.delete_file(path, prefixed_path, source_storage):
return return
# The full path of the source file # The full path of the source file
source_path = source_storage.path(path) source_path = source_storage.path(path)
# Finally start copying # Finally start copying
if options['dry_run']: if self.dry_run:
self.log(u"Pretending to copy '%s'" % source_path, level=1) self.log(u"Pretending to copy '%s'" % source_path, level=1)
else: else:
self.log(u"Copying '%s'" % source_path, level=1) self.log(u"Copying '%s'" % source_path, level=1)
@ -196,9 +234,7 @@ Type 'yes' to continue, or 'no' to cancel: """)
os.makedirs(os.path.dirname(full_path)) os.makedirs(os.path.dirname(full_path))
except OSError: except OSError:
pass pass
shutil.copy2(source_path, full_path) source_file = source_storage.open(path)
else: self.storage.save(prefixed_path, source_file)
source_file = source_storage.open(path)
self.storage.save(prefixed_path, source_file)
if not prefixed_path in self.copied_files: if not prefixed_path in self.copied_files:
self.copied_files.append(prefixed_path) self.copied_files.append(prefixed_path)

View File

@ -143,20 +143,34 @@ specified by the :setting:`INSTALLED_APPS` setting.
Some commonly used options are: Some commonly used options are:
``--noinput`` .. django-admin-option:: --noinput
Do NOT prompt the user for input of any kind. Do NOT prompt the user for input of any kind.
``-i PATTERN`` or ``--ignore=PATTERN`` .. django-admin-option:: -i <pattern>
.. django-admin-option:: --ignore <pattern>
Ignore files or directories matching this glob-style pattern. Use multiple Ignore files or directories matching this glob-style pattern. Use multiple
times to ignore more. times to ignore more.
``-n`` or ``--dry-run`` .. django-admin-option:: -n
.. django-admin-option:: --dry-run
Do everything except modify the filesystem. Do everything except modify the filesystem.
``-l`` or ``--link`` .. django-admin-option:: -c
.. django-admin-option:: --clear
.. versionadded:: 1.4
Clear the existing files before trying to copy or link the original file.
.. django-admin-option:: -l
.. django-admin-option:: --link
Create a symbolic link to each file instead of copying. Create a symbolic link to each file instead of copying.
``--no-default-ignore`` .. django-admin-option:: --no-default-ignore
Don't ignore the common private glob-style patterns ``'CVS'``, ``'.*'`` Don't ignore the common private glob-style patterns ``'CVS'``, ``'.*'``
and ``'*~'``. and ``'*~'``.

View File

@ -213,6 +213,9 @@ Django 1.4 also includes several smaller improvements worth noting:
to the :mod:`django.contrib.auth.utils` module. Importing it from the old to the :mod:`django.contrib.auth.utils` module. Importing it from the old
location will still work, but you should update your imports. location will still work, but you should update your imports.
* The :djadmin:`collectstatic` management command gained a ``--clear`` option
to delete all files at the destination before copying or linking the static
files.
.. _backwards-incompatible-changes-1.4: .. _backwards-incompatible-changes-1.4:

View File

@ -1,4 +1,5 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
from __future__ import with_statement
import codecs import codecs
import os import os
import posixpath import posixpath
@ -36,11 +37,8 @@ class StaticFilesTestCase(TestCase):
# during checkout, we actually create one file dynamically. # during checkout, we actually create one file dynamically.
_nonascii_filepath = os.path.join( _nonascii_filepath = os.path.join(
TEST_ROOT, 'apps', 'test', 'static', 'test', u'fi\u015fier.txt') TEST_ROOT, 'apps', 'test', 'static', 'test', u'fi\u015fier.txt')
f = codecs.open(_nonascii_filepath, 'w', 'utf-8') with codecs.open(_nonascii_filepath, 'w', 'utf-8') as f:
try:
f.write(u"fi\u015fier in the app dir") f.write(u"fi\u015fier in the app dir")
finally:
f.close()
self.addCleanup(os.unlink, _nonascii_filepath) self.addCleanup(os.unlink, _nonascii_filepath)
def assertFileContains(self, filepath, text): def assertFileContains(self, filepath, text):
@ -94,12 +92,8 @@ class BuildStaticTestCase(StaticFilesTestCase):
def _get_file(self, filepath): def _get_file(self, filepath):
assert filepath, 'filepath is empty.' assert filepath, 'filepath is empty.'
filepath = os.path.join(settings.STATIC_ROOT, filepath) filepath = os.path.join(settings.STATIC_ROOT, filepath)
f = codecs.open(filepath, "r", "utf-8") with codecs.open(filepath, "r", "utf-8") as f:
try:
return f.read() return f.read()
finally:
f.close()
class TestDefaults(object): class TestDefaults(object):
@ -197,9 +191,23 @@ class TestBuildStatic(BuildStaticTestCase, TestDefaults):
self.assertFileNotFound('test/CVS') self.assertFileNotFound('test/CVS')
class TestBuildStaticClear(BuildStaticTestCase):
"""
Test the ``--clear`` option of the ``collectstatic`` managemenet command.
"""
def run_collectstatic(self, **kwargs):
clear_filepath = os.path.join(settings.STATIC_ROOT, 'cleared.txt')
with open(clear_filepath, 'w') as f:
f.write('should be cleared')
super(TestBuildStaticClear, self).run_collectstatic(clear=True)
def test_cleared_not_found(self):
self.assertFileNotFound('cleared.txt')
class TestBuildStaticExcludeNoDefaultIgnore(BuildStaticTestCase, TestDefaults): class TestBuildStaticExcludeNoDefaultIgnore(BuildStaticTestCase, TestDefaults):
""" """
Test ``--exclude-dirs`` and ``--no-default-ignore`` options for Test ``--exclude-dirs`` and ``--no-default-ignore`` options of the
``collectstatic`` management command. ``collectstatic`` management command.
""" """
def run_collectstatic(self): def run_collectstatic(self):