Browse Source

Fixed #34235 -- Added ManifestFilesMixin.manifest_hash attribute.

This adds ManifestFilesMixin.manifest_hash attribute exposing a "hash"
of the full manifest. This allows applications to determine when their
static files have changed.
Florian Apolloner 2 years ago
parent
commit
afa2e28205

+ 13 - 6
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()

+ 8 - 0
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

+ 4 - 0
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`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 6 - 0
tests/staticfiles_tests/project/documents/staticfiles_v1.json

@@ -0,0 +1,6 @@
+{
+  "version": "1.0",
+  "paths": {
+    "dummy.txt": "dummy.txt"
+  }
+}

+ 27 - 4
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):