diff --git a/django/contrib/staticfiles/storage.py b/django/contrib/staticfiles/storage.py index eae25ba737..c09f01e446 100644 --- a/django/contrib/staticfiles/storage.py +++ b/django/contrib/staticfiles/storage.py @@ -439,7 +439,7 @@ class HashedFilesMixin: class ManifestFilesMixin(HashedFilesMixin): - manifest_version = "1.0" # the manifest format standard + manifest_version = "1.1" # the manifest format standard manifest_name = "staticfiles.json" manifest_strict = True keep_intermediate_files = False @@ -449,7 +449,7 @@ class ManifestFilesMixin(HashedFilesMixin): if manifest_storage is None: manifest_storage = self self.manifest_storage = manifest_storage - self.hashed_files = self.load_manifest() + self.hashed_files, self.manifest_hash = self.load_manifest() def read_manifest(self): try: @@ -461,15 +461,15 @@ class ManifestFilesMixin(HashedFilesMixin): def load_manifest(self): content = self.read_manifest() if content is None: - return {} + return {}, "" try: stored = json.loads(content) except json.JSONDecodeError: pass else: version = stored.get("version") - if version == "1.0": - return stored.get("paths", {}) + if version in ("1.0", "1.1"): + return stored.get("paths", {}), stored.get("hash", "") raise ValueError( "Couldn't load manifest '%s' (version %s)" % (self.manifest_name, self.manifest_version) @@ -482,7 +482,14 @@ class ManifestFilesMixin(HashedFilesMixin): self.save_manifest() def save_manifest(self): - payload = {"paths": self.hashed_files, "version": self.manifest_version} + self.manifest_hash = self.file_hash( + None, ContentFile(json.dumps(sorted(self.hashed_files.items())).encode()) + ) + payload = { + "paths": self.hashed_files, + "version": self.manifest_version, + "hash": self.manifest_hash, + } if self.manifest_storage.exists(self.manifest_name): self.manifest_storage.delete(self.manifest_name) contents = json.dumps(payload).encode() diff --git a/docs/ref/contrib/staticfiles.txt b/docs/ref/contrib/staticfiles.txt index 0f0e8d8001..7ca3584c33 100644 --- a/docs/ref/contrib/staticfiles.txt +++ b/docs/ref/contrib/staticfiles.txt @@ -336,6 +336,14 @@ argument. For example:: Support for finding paths to JavaScript modules in ``import`` and ``export`` statements was added. +.. attribute:: storage.ManifestStaticFilesStorage.manifest_hash + +.. versionadded:: 4.2 + +This attribute provides a single hash that changes whenever a file in the +manifest changes. This can be useful to communicate to SPAs that the assets on +the server have changed (due to a new deployment). + .. attribute:: storage.ManifestStaticFilesStorage.max_post_process_passes Since static files might reference other static files that need to have their diff --git a/docs/releases/4.2.txt b/docs/releases/4.2.txt index 826545f444..6d2fb32644 100644 --- a/docs/releases/4.2.txt +++ b/docs/releases/4.2.txt @@ -201,6 +201,10 @@ Minor features replaces paths to JavaScript modules in ``import`` and ``export`` statements with their hashed counterparts. +* The new :attr:`.ManifestStaticFilesStorage.manifest_hash` attribute provides + a hash over all files in the manifest and changes whenever one of the files + changes. + :mod:`django.contrib.syndication` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/staticfiles_tests/project/documents/staticfiles_v1.json b/tests/staticfiles_tests/project/documents/staticfiles_v1.json new file mode 100644 index 0000000000..4f85945e3f --- /dev/null +++ b/tests/staticfiles_tests/project/documents/staticfiles_v1.json @@ -0,0 +1,6 @@ +{ + "version": "1.0", + "paths": { + "dummy.txt": "dummy.txt" + } +} diff --git a/tests/staticfiles_tests/test_storage.py b/tests/staticfiles_tests/test_storage.py index 077d14bcc4..f2f1899aac 100644 --- a/tests/staticfiles_tests/test_storage.py +++ b/tests/staticfiles_tests/test_storage.py @@ -436,7 +436,7 @@ class TestCollectionManifestStorage(TestHashedFiles, CollectionTestCase): # The in-memory version of the manifest matches the one on disk # since a properly created manifest should cover all filenames. if hashed_files: - manifest = storage.staticfiles_storage.load_manifest() + manifest, _ = storage.staticfiles_storage.load_manifest() self.assertEqual(hashed_files, manifest) def test_manifest_exists(self): @@ -463,7 +463,7 @@ class TestCollectionManifestStorage(TestHashedFiles, CollectionTestCase): def test_parse_cache(self): hashed_files = storage.staticfiles_storage.hashed_files - manifest = storage.staticfiles_storage.load_manifest() + manifest, _ = storage.staticfiles_storage.load_manifest() self.assertEqual(hashed_files, manifest) def test_clear_empties_manifest(self): @@ -476,7 +476,7 @@ class TestCollectionManifestStorage(TestHashedFiles, CollectionTestCase): hashed_files = storage.staticfiles_storage.hashed_files self.assertIn(cleared_file_name, hashed_files) - manifest_content = storage.staticfiles_storage.load_manifest() + manifest_content, _ = storage.staticfiles_storage.load_manifest() self.assertIn(cleared_file_name, manifest_content) original_path = storage.staticfiles_storage.path(cleared_file_name) @@ -491,7 +491,7 @@ class TestCollectionManifestStorage(TestHashedFiles, CollectionTestCase): hashed_files = storage.staticfiles_storage.hashed_files self.assertNotIn(cleared_file_name, hashed_files) - manifest_content = storage.staticfiles_storage.load_manifest() + manifest_content, _ = storage.staticfiles_storage.load_manifest() self.assertNotIn(cleared_file_name, manifest_content) def test_missing_entry(self): @@ -535,6 +535,29 @@ class TestCollectionManifestStorage(TestHashedFiles, CollectionTestCase): 2, ) + def test_manifest_hash(self): + # Collect the additional file. + self.run_collectstatic() + + _, manifest_hash_orig = storage.staticfiles_storage.load_manifest() + self.assertNotEqual(manifest_hash_orig, "") + self.assertEqual(storage.staticfiles_storage.manifest_hash, manifest_hash_orig) + # Saving doesn't change the hash. + storage.staticfiles_storage.save_manifest() + self.assertEqual(storage.staticfiles_storage.manifest_hash, manifest_hash_orig) + # Delete the original file from the app, collect with clear. + os.unlink(self._clear_filename) + self.run_collectstatic(clear=True) + # Hash is changed. + _, manifest_hash = storage.staticfiles_storage.load_manifest() + self.assertNotEqual(manifest_hash, manifest_hash_orig) + + def test_manifest_hash_v1(self): + storage.staticfiles_storage.manifest_name = "staticfiles_v1.json" + manifest_content, manifest_hash = storage.staticfiles_storage.load_manifest() + self.assertEqual(manifest_hash, "") + self.assertEqual(manifest_content, {"dummy.txt": "dummy.txt"}) + @override_settings(STATICFILES_STORAGE="staticfiles_tests.storage.NoneHashStorage") class TestCollectionNoneHashStorage(CollectionTestCase):