Skip to content

Commit 6b91822

Browse files
committed
Allow callables and bools for nocompress, add rotate_storage command
1 parent 0365117 commit 6b91822

File tree

9 files changed

+101
-3
lines changed

9 files changed

+101
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Drop support for Python 3.9
44
* Test on Python 3.14 and Django 5.2
55
* Support opening files for writing
6+
* Allow `nocompress` option to accept a callable, or `True` to indicate never compress
7+
* Experimental `rotate_storage` Django command for rotating file fields stored in `PZipStorage`
68

79

810
## 1.2.1 (2024-11-08)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The simplest way to use `PZipStorage` is by setting your
1616
without first rotating the keys of all stored files, they will be lost forever.
1717

1818
`PZipStorage` may be used with existing unencrypted files, as a drop-in replacement for `FileSystemStorage`. If it
19-
determines the requested file is not a PZip file, it will delegate to `FileSystemStorage` after emitting a
19+
determined the requested file is not a PZip file, it will delegate to `FileSystemStorage` after emitting a
2020
`needs_encryption` signal (see below).
2121

2222
You may also use `PZipStorage` as a custom storage backend anywhere Django allows it; see

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "django-pzip-storage"
3-
version = "1.3.0"
3+
version = "1.3.0.dev0"
44
description = "Storage backend for Django that encrypts/compresses with PZip."
55
readme = "README.md"
66
authors = [

src/pzip_storage/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ def __init__(self, *args, **kwargs):
7373
"nocompress",
7474
getattr(settings, "PZIP_STORAGE_NOCOMPRESS", self.DEFAULT_NOCOMPRESS),
7575
)
76+
if callable(self.nocompress):
77+
self.nocompress = self.nocompress()
7678
if not self.keys:
7779
raise ImproperlyConfigured("PZipStorage requires at least one key.")
7880
super().__init__(*args, **kwargs)
@@ -129,6 +131,8 @@ def _open(self, name, mode="rb"):
129131
return super()._open(name, mode)
130132

131133
def should_compress(self, name):
134+
if isinstance(self.nocompress, bool):
135+
return not self.nocompress
132136
return os.path.splitext(name)[1].lower() not in self.nocompress
133137

134138
def get_write_key(self):

src/pzip_storage/management/__init__.py

Whitespace-only changes.

src/pzip_storage/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import os
2+
3+
from django.apps import apps
4+
from django.core.management.base import BaseCommand
5+
from django.db import models, transaction
6+
7+
from pzip_storage import PZipStorage
8+
9+
10+
def rotate_model(model, fields, storage_changes, save=True, verbosity=0):
11+
for instance in model.objects.all():
12+
changed = []
13+
for field in fields:
14+
old = getattr(instance, field.name)
15+
if not old:
16+
continue
17+
path_changes = storage_changes.setdefault(field.storage, {})
18+
if old.name in path_changes:
19+
raise ValueError(f"Duplicate path in {instance!r}: {old.name}")
20+
try:
21+
new_name = field.generate_filename(instance, os.path.basename(old.name))
22+
new_path = field.storage.save(new_name, old)
23+
setattr(instance, field.attname, new_path)
24+
changed.append(field.attname)
25+
path_changes[old.name] = new_path
26+
if not save and verbosity > 1:
27+
print(old.name, "->", new_path)
28+
except OSError as e:
29+
if verbosity > 0:
30+
print("error rotating", old, "-", str(e))
31+
if changed and save:
32+
instance.save(update_fields=changed)
33+
34+
35+
class Command(BaseCommand):
36+
help = "Rotates FileFields stored in PZipStorage"
37+
38+
def add_arguments(self, parser):
39+
parser.add_argument("-n", "--dry-run", action="store_true", default=False)
40+
parser.add_argument("--delete", action="store_true", default=False)
41+
parser.add_argument("models", nargs="*")
42+
43+
def handle(self, *args, **options):
44+
verbosity = options["verbosity"]
45+
save = not options["dry_run"]
46+
rotate_models = [apps.get_model(m) for m in options["models"]]
47+
if not rotate_models:
48+
rotate_models = apps.get_models()
49+
# Track {storage: {old_path: new_path}}
50+
storage_changes = {}
51+
with transaction.atomic():
52+
for model in rotate_models:
53+
file_fields = [
54+
f
55+
for f in model._meta.fields
56+
if isinstance(f, models.FileField)
57+
and isinstance(f.storage, PZipStorage)
58+
]
59+
if file_fields:
60+
rotate_model(
61+
model,
62+
file_fields,
63+
storage_changes,
64+
save=save,
65+
verbosity=verbosity,
66+
)
67+
if options["delete"]:
68+
for storage, path_changes in storage_changes.items():
69+
if save:
70+
# Real run, delete the old files.
71+
delete_paths = set(path_changes.keys())
72+
else:
73+
# For a dry run, delete the newly-created files.
74+
delete_paths = set(path_changes.values())
75+
for path in delete_paths:
76+
try:
77+
storage.delete(path)
78+
if verbosity > 1:
79+
print(f"removed {path}")
80+
except Exception as e:
81+
if verbosity > 0:
82+
print("error removing", path, "-", str(e))

tests.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ def test_no_compression(self):
6060
with self.storage.open(name) as f:
6161
self.assertIsInstance(f, pzip.PZip)
6262
self.assertEqual(f.compression, pzip.Compression.NONE)
63+
# Compress everything.
64+
compressed = PZipStorage(nocompress=False)
65+
with compressed.open("test.jpg", "wb") as f:
66+
self.assertIsInstance(f, pzip.PZip)
67+
self.assertEqual(f.compression, pzip.Compression.GZIP)
68+
# Compress nothing.
69+
raw = PZipStorage(nocompress=True)
70+
with raw.open("test.txt", "wb") as f:
71+
self.assertIsInstance(f, pzip.PZip)
72+
self.assertEqual(f.compression, pzip.Compression.NONE)
6373

6474
def test_unencrypted(self):
6575
handler = MagicMock()

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)