diff --git a/django/contrib/staticfiles/management/commands/collectstatic.py b/django/contrib/staticfiles/management/commands/collectstatic.py index d5cd3f56ca..7f1d612bf0 100644 --- a/django/contrib/staticfiles/management/commands/collectstatic.py +++ b/django/contrib/staticfiles/management/commands/collectstatic.py @@ -25,6 +25,7 @@ class Command(BaseCommand): self.symlinked_files = [] self.unmodified_files = [] self.post_processed_files = [] + self.skipped_files = [] self.storage = staticfiles_storage self.style = no_style() @@ -134,12 +135,13 @@ class Command(BaseCommand): found_files[prefixed_path] = (storage, path) handler(path, prefixed_path, storage) else: + self.skipped_files.append(prefixed_path) self.log( "Found another file with the destination path '%s'. It " "will be ignored since only the first encountered file " "is collected. If this is not what you want, make sure " "every static file has a unique path." % prefixed_path, - level=1, + level=2, ) # Storage backends may define a post_process() method. @@ -165,6 +167,7 @@ class Command(BaseCommand): "modified": self.copied_files + self.symlinked_files, "unmodified": self.unmodified_files, "post_processed": self.post_processed_files, + "skipped": self.skipped_files, } def handle(self, **options): @@ -212,9 +215,10 @@ class Command(BaseCommand): modified_count = len(collected["modified"]) unmodified_count = len(collected["unmodified"]) post_processed_count = len(collected["post_processed"]) + skipped_count = len(collected["skipped"]) return ( "\n%(modified_count)s %(identifier)s %(action)s" - "%(destination)s%(unmodified)s%(post_processed)s." + "%(destination)s%(unmodified)s%(post_processed)s%(skipped)s." ) % { "modified_count": modified_count, "identifier": "static file" + ("" if modified_count == 1 else "s"), @@ -232,6 +236,11 @@ class Command(BaseCommand): and ", %s post-processed" % post_processed_count or "" ), + "skipped": ( + ", %s skipped due to conflict" % skipped_count + if collected["skipped"] + else "" + ), } def log(self, msg, level=2): diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index 828e2d0354..4afc172da9 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -470,6 +470,10 @@ Miscellaneous * The minimum supported version of ``asgiref`` is increased from 3.8.1 to 3.9.1. +* The :djadmin:`collectstatic` command now reports only a summary of skipped + files due to conflicts when ``--verbosity`` is 1. To see warnings for each + conflicting destination path, set the ``--verbosity`` flag to 2 or higher. + .. _deprecated-features-6.0: Features deprecated in 6.0 diff --git a/tests/staticfiles_tests/test_management.py b/tests/staticfiles_tests/test_management.py index e8873915e6..9555c54093 100644 --- a/tests/staticfiles_tests/test_management.py +++ b/tests/staticfiles_tests/test_management.py @@ -508,15 +508,17 @@ class TestCollectionOverwriteWarning(CollectionTestCase): # looking for was emitted. warning_string = "Found another file" - def _collectstatic_output(self, **kwargs): + def _collectstatic_output(self, verbosity=3, **kwargs): """ - Run collectstatic, and capture and return the output. We want to run - the command at highest verbosity, which is why we can't - just call e.g. BaseCollectionTestCase.run_collectstatic() + Run collectstatic, and capture and return the output. """ out = StringIO() call_command( - "collectstatic", interactive=False, verbosity=3, stdout=out, **kwargs + "collectstatic", + interactive=False, + verbosity=verbosity, + stdout=out, + **kwargs, ) return out.getvalue() @@ -527,9 +529,10 @@ class TestCollectionOverwriteWarning(CollectionTestCase): output = self._collectstatic_output(clear=True) self.assertNotIn(self.warning_string, output) - def test_warning(self): + def test_warning_at_verbosity_2(self): """ - There is a warning when there are duplicate destinations. + There is a warning when there are duplicate destinations at verbosity + 2+. """ with tempfile.TemporaryDirectory() as static_dir: duplicate = os.path.join(static_dir, "test", "file.txt") @@ -538,15 +541,42 @@ class TestCollectionOverwriteWarning(CollectionTestCase): f.write("duplicate of file.txt") with self.settings(STATICFILES_DIRS=[static_dir]): - output = self._collectstatic_output(clear=True) + output = self._collectstatic_output(clear=True, verbosity=2) self.assertIn(self.warning_string, output) - os.remove(duplicate) + def test_no_warning_at_verbosity_1(self): + """ + There is no individual warning at verbosity 1, but summary is shown. + """ + with tempfile.TemporaryDirectory() as static_dir: + duplicate = os.path.join(static_dir, "test", "file.txt") + os.mkdir(os.path.dirname(duplicate)) + with open(duplicate, "w+") as f: + f.write("duplicate of file.txt") - # Make sure the warning went away again. with self.settings(STATICFILES_DIRS=[static_dir]): - output = self._collectstatic_output(clear=True) + output = self._collectstatic_output(clear=True, verbosity=1) self.assertNotIn(self.warning_string, output) + self.assertIn("1 skipped due to conflict", output) + + def test_summary_multiple_conflicts(self): + """ + Summary shows correct count for multiple conflicts. + """ + with tempfile.TemporaryDirectory() as static_dir: + duplicate1 = os.path.join(static_dir, "test", "file.txt") + os.makedirs(os.path.dirname(duplicate1)) + with open(duplicate1, "w+") as f: + f.write("duplicate of file.txt") + duplicate2 = os.path.join(static_dir, "test", "file1.txt") + with open(duplicate2, "w+") as f: + f.write("duplicate of file1.txt") + duplicate3 = os.path.join(static_dir, "test", "nonascii.css") + shutil.copy2(duplicate1, duplicate3) + + with self.settings(STATICFILES_DIRS=[static_dir]): + output = self._collectstatic_output(clear=True, verbosity=1) + self.assertIn("3 skipped due to conflict", output) @override_settings(