Compare commits

..
17 Commits
Author SHA1 Message Date
Tris Forster 9903bb0088 Updated cache to not use get_storage_class 2024-08-26 11:55:38 +10:00
Tris Forster f134df3384 Version bump 2024-08-26 11:49:10 +10:00
Tris Forster 92d64a57d2 get_storage_class removed in Django 5 2024-08-26 11:37:29 +10:00
tris 43db0b0983 Update 'README.rst' 2023-02-19 21:37:40 +11:00
Tris Forster 227ce5fccb Fixed passing Storage instance 2022-12-21 08:22:14 +11:00
Tris Forster 432118ac58 Version bump 2022-12-21 08:08:36 +11:00
Tris Forster 49646dfafa Dont cache if local 2022-12-21 08:03:40 +11:00
Tris 4d87edd995 Better defaults for user storage 2022-10-19 09:17:14 +11:00
Tris 4284b4a05f Added missing path() method 2022-10-19 09:02:34 +11:00
Tris 845762400f Add all storages classes 2022-09-10 15:25:13 +10:00
Tris 0ad2812ed6 Version bump 2022-09-10 14:49:14 +10:00
Tris be8954b505 Clean up 2022-09-10 14:47:17 +10:00
Tris aa130808d4 Make name the PK 2021-09-17 15:10:53 +10:00
Tris 8496309ad7 Couple of changes 2021-09-17 14:58:07 +10:00
Tris 11f8a07887 Moved tests to package 2021-09-17 14:55:32 +10:00
Tris c67d636d24 Validators 2021-03-24 15:52:12 +11:00
Tris 428938ed8c Validate storage on save 2021-03-23 08:43:24 +11:00
18 changed files with 297 additions and 81 deletions
+1
View File
@@ -3,3 +3,4 @@ __pycache__
django_byostorage.egg-info django_byostorage.egg-info
.coverage .coverage
htmlcov htmlcov
db.sqlite3
+5
View File
@@ -23,3 +23,8 @@ Usage
photo = models.FileField( photo = models.FileField(
storage=BYOStorage(), storage=BYOStorage(),
upload_to=generate_filename) upload_to=generate_filename)
Notes
=====
Boto3 client sometimes doesn't respect the `region_name` so it may be neccesary to set `endpoint_url: https://https://s3-<region>.amazonaws.com`
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = 0.1 __version__ = '0.2.4'
default_app_config = 'byostorage.apps.BYOStorageConfig' default_app_config = 'byostorage.apps.BYOStorageConfig'
+1
View File
@@ -4,3 +4,4 @@ from django.apps import AppConfig
class BYOStorageConfig(AppConfig): class BYOStorageConfig(AppConfig):
name = 'byostorage' name = 'byostorage'
verbose_name = 'Bring your own storage' verbose_name = 'Bring your own storage'
default_auto_field = 'django.db.models.AutoField'
+12 -4
View File
@@ -1,5 +1,6 @@
from django.core.files.storage import Storage, get_storage_class from django.core.files.storage import Storage, DefaultStorage
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.module_loading import import_string
from django.conf import settings from django.conf import settings
from hashlib import sha1 from hashlib import sha1
import os.path import os.path
@@ -15,11 +16,14 @@ class CachedStorage(Storage):
def __init__(self, remote=None, cachedir=None, expires=600): def __init__(self, remote=None, cachedir=None, expires=600):
if not remote: if not remote:
remote = getattr(settings, 'CACHED_STORAGE_REMOTE', None) remote = getattr(settings, 'CACHED_STORAGE_REMOTE', DefaultStorage)
if not cachedir: if not cachedir:
cachedir = getattr(settings, 'CACHED_STORAGE_DIR', 'cache') cachedir = getattr(settings, 'CACHED_STORAGE_DIR', 'cache')
self.remote = get_storage_class(remote)() if isinstance(remote, Storage):
self.remote = remote
else:
self.remote = import_string(remote)()
self.cachedir = cachedir self.cachedir = cachedir
self.expires = expires self.expires = expires
@@ -52,16 +56,20 @@ class CachedStorage(Storage):
def _open(self, name, mode='rb'): def _open(self, name, mode='rb'):
assert 'r' in mode, "Can only open for reading" assert 'r' in mode, "Can only open for reading"
p = self._cached(name) p = self.path(name)
return open(p, mode) return open(p, mode)
def path(self, name): def path(self, name):
try:
return self.remote.path(name)
except NotImplementedError:
return self._cached(name) return self._cached(name)
def save(self, name, content, max_length=None): def save(self, name, content, max_length=None):
p = self._filepath(name) p = self._filepath(name)
if os.path.exists(p): if os.path.exists(p):
os.unlink(p) os.unlink(p)
# TODO: cache content to p
return self.remote.save(name, content, max_length) return self.remote.save(name, content, max_length)
def delete(self, name): def delete(self, name):
+4
View File
@@ -5,11 +5,15 @@ BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_ROOT = "media" MEDIA_ROOT = "media"
DEBUG = True
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'byostorage' 'byostorage'
] ]
SECRET_KEY = 'shh!' SECRET_KEY = 'shh!'
DATABASES = { DATABASES = {
@@ -0,0 +1,19 @@
# Generated by Django 3.1.1 on 2021-03-22 23:47
import byostorage.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('byostorage', '0002_auto_20210312_1712'),
]
operations = [
migrations.AlterField(
model_name='userstorage',
name='settings_data',
field=models.TextField(help_text='JSON dict with key/value settings', validators=[byostorage.models.validate_json]),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 3.2.7 on 2021-09-17 00:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('byostorage', '0003_auto_20210323_1047'),
]
operations = [
migrations.RemoveField(
model_name='userstorage',
name='id',
),
migrations.AlterField(
model_name='userstorage',
name='name',
field=models.SlugField(help_text='Storage tag', max_length=20, primary_key=True, serialize=False),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.7 on 2022-09-09 23:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('byostorage', '0004_auto_20210917_0000'),
]
operations = [
migrations.AlterField(
model_name='userstorage',
name='storage',
field=models.CharField(choices=[('django.core.files.storage.FileSystemStorage', 'django.core.files.storage.FileSystemStorage')], help_text='Storage class for this instance', max_length=255),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 3.2.7 on 2022-09-10 00:10
import byostorage.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('byostorage', '0005_alter_userstorage_storage'),
]
operations = [
migrations.AlterField(
model_name='userstorage',
name='settings_data',
field=models.TextField(default='{}', help_text='JSON dict with key/value settings', validators=[byostorage.models.validate_json]),
),
]
+77 -5
View File
@@ -1,9 +1,16 @@
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.core.files.storage import get_storage_class, FileSystemStorage from django.db.models.fields.files import FieldFile
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.storage import Storage
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.module_loading import import_string
import json import json
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
STORAGE_CLASSES = getattr(settings, 'STORAGE_CLASSES', []) STORAGE_CLASSES = getattr(settings, 'STORAGE_CLASSES', [])
STORAGE_CLASSES.extend([ STORAGE_CLASSES.extend([
@@ -11,8 +18,16 @@ STORAGE_CLASSES.extend([
]) ])
try: try:
import storages import storages.backends
STORAGE_CLASSES.append('storages.backends.s3boto3.S3Boto3Storage')
STORAGE_CLASSES.extend([
'storages.backends.s3boto3.S3Boto3Storage',
'storages.backends.azure_storage.AzureStorage',
'storages.backends.dropbox.DropBoxStorage',
'storages.backends.ftp.FTPStorage',
'storages.backends.gcloud.GoogleCloudStorage',
'storages.backends.sftpstorage.SFTPStorage',
])
except ImportError: except ImportError:
pass pass
@@ -24,6 +39,15 @@ def validate_json(value):
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise ValidationError(e.msg) raise ValidationError(e.msg)
def resolve_instance(obj, relation):
for p in relation.split('__'):
obj = getattr(obj, p)
if callable(obj):
obj = obj()
return obj
class UserStorage(models.Model): class UserStorage(models.Model):
""" A user defined storage """ A user defined storage
""" """
@@ -31,17 +55,18 @@ class UserStorage(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True, blank=True) null=True, blank=True)
name = models.SlugField(max_length=20, name = models.SlugField(max_length=20,
unique=True, primary_key=True,
help_text="Storage tag") help_text="Storage tag")
storage = models.CharField(max_length=255, storage = models.CharField(max_length=255,
choices=STORAGE_CLASS_OPTIONS, choices=STORAGE_CLASS_OPTIONS,
help_text="Storage class for this instance") help_text="Storage class for this instance")
settings_data = models.TextField( settings_data = models.TextField(
validators=[validate_json], validators=[validate_json],
default='{}',
help_text="JSON dict with key/value settings") help_text="JSON dict with key/value settings")
def instance(self): def instance(self):
return get_storage_class(self.storage)(**self.settings) return import_string(self.storage)(**self.settings)
@property @property
def settings(self): def settings(self):
@@ -52,6 +77,12 @@ class UserStorage(models.Model):
except Exception as e: except Exception as e:
raise ValueError("Error in settings for storage '{0}' [{1}]".format(self.name, e)) raise ValueError("Error in settings for storage '{0}' [{1}]".format(self.name, e))
def clean(self):
try:
self.test_storage()
except Exception as e:
raise ValidationError(str(e))
def test_storage(self): def test_storage(self):
# just do something that requires connection # just do something that requires connection
try: try:
@@ -70,3 +101,44 @@ class UserStorage(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
class BYOFieldFile(FieldFile):
def __init__(self, instance, field, name):
logger.debug("BYOFieldFile(%r, %r, %r)", instance, field, name)
super().__init__(instance, field, name)
try:
self.storage = field.get_storage(instance)
logger.debug("Using BYO storage: %r", self.storage)
except ObjectDoesNotExist:
logger.debug("Unable to select BYO storage")
self.storage = None # trigger error if try to save etc
class BYOStorageField(models.FileField):
attr_class = BYOFieldFile
def __init__(self, storage_instance, *args, **kwargs):
self.storage_instance = storage_instance
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
args = [self.storage_instance] + args
return name, path, args, kwargs
def get_storage(self, instance):
if callable(self.storage_instance):
storage = self.storage_instance(instance)
else:
storage = resolve_instance(instance, self.storage_instance)
if not isinstance(storage, Storage):
raise RuntimeError("Not a storage instance")
return storage
#def formfield(self, **kwargs):
# return BYOStorageFormField(**kwargs)
+8 -4
View File
@@ -1,6 +1,7 @@
from django.core.files.storage import Storage, default_storage, get_storage_class from django.core.files.storage import Storage, default_storage, storages
from django.conf import settings from django.conf import settings
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.module_loading import import_string
''' '''
TODO: Create a signal to remove instances from cache if modified TODO: Create a signal to remove instances from cache if modified
@@ -28,7 +29,7 @@ class MultiStorage(Storage):
config = self.config[name] config = self.config[name]
except KeyError: except KeyError:
return default_storage return default_storage
klass = get_storage_class(config.pop('storage', None)) klass = import_string(config.pop('storage', 'django.core.files.storage.FileSystemStorage'))
self._cache[name] = klass(**config) self._cache[name] = klass(**config)
return self._cache[name] return self._cache[name]
@@ -45,18 +46,21 @@ class MultiStorage(Storage):
def _proxy(self, method, name, *args, **kwargs): def _proxy(self, method, name, *args, **kwargs):
storage, p = self.split(name) storage, p = self.split(name)
result = getattr(self.get_storage(storage), method)(p, *args, **kwargs) result = getattr(self.get_storage(storage), method)(p, *args, **kwargs)
logger.debug("Proxy: %s(%r, *%r, **%r) => %r", method, name, args, kwargs, result) logger.debug("Proxy: %s(%r, *%r, **%r) => %r", method, p, args, kwargs, result)
return result return result
def _proxy_name(self, method, name, *args, **kwargs): def _proxy_name(self, method, name, *args, **kwargs):
storage, p = self.split(name) storage, p = self.split(name)
result = getattr(self.get_storage(storage), method)(p, *args, **kwargs) result = getattr(self.get_storage(storage), method)(p, *args, **kwargs)
logger.debug("Proxy: %s(%r, *%r, **%r) => %s:%s", method, name, args, kwargs, storage, result) logger.debug("Proxy: %s(%r, *%r, **%r) => %s:%s", method, p, args, kwargs, storage, result)
return self.join(storage, result) return self.join(storage, result)
def _open(self, name, mode='rb'): def _open(self, name, mode='rb'):
return self._proxy('open', name, mode) return self._proxy('open', name, mode)
def path(self, name):
return self._proxy('path', name)
def save(self, name, content, max_length=None): def save(self, name, content, max_length=None):
return self._proxy_name('save', name, content, max_length) return self._proxy_name('save', name, content, max_length)
View File
@@ -2,46 +2,17 @@ from django.test import TestCase
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from .multi import MultiStorage from byostorage.models import UserStorage
from .models import UserStorage from byostorage.user import BYOStorage
from .user import BYOStorage
from .http import HTTPStorage #from example.models import LinkedStorageTestModel
import tempfile
import os.path
#import logging #import logging
#logging.basicConfig(level=logging.DEBUG) #logging.basicConfig(level=logging.DEBUG)
class MultiStorageTestCase(TestCase):
def test_storage_selection(self):
ms = MultiStorage({'one': {'location': 'storage/one'}, 'two': {}})
self.assertEqual(str(ms), "<MultiStorage: 2 storages>")
# use the default if nothing set
default = ms.get_storage('foo')
self.assertIsInstance(default, FileSystemStorage)
self.assertEqual(default.base_location, 'media')
# get first instance
one = ms.get_storage('one')
self.assertEqual(one.base_location, 'storage/one')
def test_storage_proxies(self):
ms = MultiStorage({
'one': {'location': 'storage/one', 'base_url': 'http://one'},
'two': {'location': 'storage/two', 'base_url': 'http://two'}
})
self.assertEqual(ms.url("one:foo.txt"), 'http://one/foo.txt')
self.assertEqual(ms.url("two:foo.txt"), 'http://two/foo.txt')
def test_with_http(self):
ms = MultiStorage({
'https': {'storage': 'byostorage.http.HTTPStorage', 'protocol': 'https'}
})
self.assertEqual(ms.url('https://google.com'), 'https://google.com')
class UserStorageTestCase(TestCase): class UserStorageTestCase(TestCase):
def test_construction(self): def test_construction(self):
@@ -63,10 +34,10 @@ class UserStorageTestCase(TestCase):
instance = UserStorage.objects.create(name='one', storage='storages.backends.s3boto3.S3Boto3Storage', instance = UserStorage.objects.create(name='one', storage='storages.backends.s3boto3.S3Boto3Storage',
settings_data=''' settings_data='''
{ {
"access_key": "polyphonic_test_key", "access_key": "minioadmin",
"secret_key": "polyphonic_secret", "secret_key": "minioadmin",
"endpoint_url": "http://localhost:9000", "endpoint_url": "http://localhost:9000",
"bucket_name": "personal" "bucket_name": "byostorage_test"
} }
''') ''')
@@ -76,10 +47,10 @@ class UserStorageTestCase(TestCase):
UserStorage.objects.create(name='one', storage='storages.backends.s3boto3.S3Boto3Storage', UserStorage.objects.create(name='one', storage='storages.backends.s3boto3.S3Boto3Storage',
settings_data=''' settings_data='''
{ {
"access_key": "polyphonic_test_key", "access_key": "minioadmin",
"secret_key": "the_wrong_secret", "secret_key": "the_wrong_secret",
"endpoint_url": "http://localhost:9000", "endpoint_url": "http://localhost:9000",
"bucket_name": "missing" "bucket_name": "byostorage_test"
} }
''') ''')
self.fail("Should have raised an exception") self.fail("Should have raised an exception")
@@ -113,28 +84,29 @@ class BYOStorageTestCase(TestCase):
one = storage.get_storage('one') one = storage.get_storage('one')
self.assertEqual(one.base_location, 'other/one') self.assertEqual(one.base_location, 'other/one')
"""
class BYOStorageFieldTestCase(TestCase):
class HTTPStorageTestCase(TestCase): def setUp(self):
self.test_dir = tempfile.TemporaryDirectory()
self.one = UserStorage.objects.create(name='one', storage='django.core.files.storage.FileSystemStorage',
settings_data = '{"location": "%s/one"}' % self.test_dir.name)
def test_url(self): def tearDown(self):
s = HTTPStorage() self.test_dir.cleanup()
self.assertEqual(s.url('//google.com'), 'https://google.com') def test_linked_storage(self):
def test_exists(self): from django.core.files.uploadedfile import UploadedFile
s = HTTPStorage()
self.assertTrue(s.exists('//gitea.tfconsulting.com.au/tris')) with open(__file__, 'rb') as f:
self.assertFalse(s.exists('//gitea.tfconsulting.com.au/foo.txt')) result = LinkedStorageTestModel.objects.create(name="first", my_storage_id=self.one.pk, my_file=UploadedFile(f))
def test_save(self): expected = os.path.basename(__file__)
s = HTTPStorage()
with self.assertRaisesMessage(NotImplementedError, "Unable to save to web locations"):
s.save('//gitea.tfconsulting.com.au/foo', 'Some content')
def test_open(self): self.assertEqual(result.my_file.name, expected)
s = HTTPStorage() self.assertGreater(result.my_file.size, 0)
with s.open('//gitea.tfconsulting.com.au', 'rb') as f:
data = f.read()
self.assertTrue(len(data) > 4000) self.assertEqual(os.listdir(os.path.join(self.test_dir.name, 'one')), [expected])
"""
+29
View File
@@ -0,0 +1,29 @@
from django.test import TestCase
from byostorage.http import HTTPStorage
class HTTPStorageTestCase(TestCase):
def test_url(self):
s = HTTPStorage()
self.assertEqual(s.url('//google.com'), 'https://google.com')
def test_exists(self):
s = HTTPStorage()
self.assertTrue(s.exists('//gitea.tfconsulting.com.au/tris'))
self.assertFalse(s.exists('//gitea.tfconsulting.com.au/foo.txt'))
def test_save(self):
return # already caught as suspicious activity
s = HTTPStorage()
with self.assertRaisesMessage(NotImplementedError, "Unable to save to web locations"):
s.save('//gitea.tfconsulting.com.au/foo', 'Some content')
def test_open(self):
s = HTTPStorage()
with s.open('//gitea.tfconsulting.com.au', 'rb') as f:
data = f.read()
self.assertTrue(len(data) > 4000)
+44
View File
@@ -0,0 +1,44 @@
from django.test import TestCase
from byostorage.multi import MultiStorage
from django.core.files.storage import FileSystemStorage
import os.path
class MultiStorageTestCase(TestCase):
def test_storage_selection(self):
ms = MultiStorage({'one': {'location': 'storage/one'}, 'two': {}})
self.assertEqual(str(ms), "<MultiStorage: 2 storages>")
# use the default if nothing set
default = ms.get_storage('foo')
self.assertIsInstance(default, FileSystemStorage)
self.assertEqual(default.base_location, 'media')
# get first instance
one = ms.get_storage('one')
self.assertEqual(one.base_location, 'storage/one')
def test_storage_proxies(self):
ms = MultiStorage({
'one': {'location': 'storage/one', 'base_url': 'http://one'},
'two': {'location': 'storage/two', 'base_url': 'http://two'}
})
self.assertEqual(ms.url("one:foo.txt"), 'http://one/foo.txt')
self.assertEqual(ms.url("two:foo.txt"), 'http://two/foo.txt')
def test_with_http(self):
ms = MultiStorage({
'https': {'storage': 'byostorage.http.HTTPStorage', 'protocol': 'https'}
})
self.assertEqual(ms.url('https://google.com'), 'https://google.com')
def test_local_methods(self):
ms = MultiStorage({
'local': {'storage': 'django.core.files.storage.FileSystemStorage', 'location': 'local_store'}
})
self.assertEqual(ms.path('local:foo/bar.txt'), os.path.realpath('local_store/foo/bar.txt'))
-1
View File
@@ -6,7 +6,6 @@ from .multi import MultiStorage
class BYOStorage(MultiStorage): class BYOStorage(MultiStorage):
''' Database driven Bring-Your-Own-Storage ''' Database driven Bring-Your-Own-Storage
Multiple storages can
''' '''
def __init__(self, config=None): def __init__(self, config=None):
+3 -4
View File
@@ -11,9 +11,8 @@ classifiers =
Development Status :: 1 - Development Development Status :: 1 - Development
Environment :: Web Environment Environment :: Web Environment
Framework :: Django Framework :: Django
Framework :: Django :: 2.2 Framework :: Django :: 4.2
Framework :: Django :: 3.0 Framework :: Django :: 5.0
Framework :: Django :: 3.1
Intended Audience :: Developers Intended Audience :: Developers
License :: OSI Approved :: BSD License License :: OSI Approved :: BSD License
Operating System :: OS Independent Operating System :: OS Independent
@@ -29,7 +28,7 @@ classifiers =
[options] [options]
python_requires = >=3.5 python_requires = >=3.5
install_requires = install_requires =
Django >= 2.2 Django >= 4.2
packages = packages =
byostorage byostorage
byostorage.migrations byostorage.migrations