├── .github └── workflows │ └── tests.yml ├── .gitignore ├── HISTORY.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── override_storage ├── __init__.py ├── __version__.py ├── runner.py ├── storage.py └── utils.py ├── requirements.txt ├── runtests.py ├── setup.py ├── tests ├── __init__.py ├── context.py ├── models.py ├── test.py └── test_settings.py └── tox.ini /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | python-version: [3.6, 3.7, 3.8, 3.9] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip tox tox-factor 22 | - name: Run tox targets for ${{ matrix.python-version }} 23 | run: | 24 | PYVERSION=$(python -c "import sys; print(''.join([str(sys.version_info.major), str(sys.version_info.minor)]))") 25 | python -m tox -f "py${PYVERSION}" 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | django_override_storage.egg-info 4 | *.pyc 5 | *.swp 6 | *.egg 7 | 8 | tests/media/ 9 | tests/test.db 10 | -------------------------------------------------------------------------------- /HISTORY.txt: -------------------------------------------------------------------------------- 1 | 0.1.2 2 | - Fix #1 bug with using override_storage and locmem_stats_override_storage as 3 | decorators without parenthesis. 4 | - Change override_storage and locmem_stats_override_storage kwarg names 5 | to be more concise now they will be used more often after the fix of #1 6 | (and internal attribute names to match). 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Daniel Hillier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Override Storage 2 | ======================= 3 | 4 | Stop filling up your disk with test files or your code with file system mocks! 5 | 6 | This project provides tools to help you reduce the side effects of using 7 | FileFields during tests. 8 | 9 | 10 | Installation 11 | ------------ 12 | 13 | .. code-block:: bash 14 | 15 | pip install django-override-storage 16 | 17 | 18 | Simple Usage 19 | ------------ 20 | Calling ``override_storage()`` without any arguments will patch all 21 | ``FileField`` fields to store the contents of the file in an in-memory cache 22 | and returns the fields to their previous storages when leaving its context. The 23 | storage cache is deleted at the end of each test or when exiting the context 24 | manager depending on how it is called. 25 | 26 | It can be used similarly to ``django.test.utils.override_settings``: as a class 27 | decorator, a method decorator or a context manager. 28 | 29 | .. code-block:: python 30 | 31 | from django.core.files.base import ContentFile 32 | from django.test import TestCase 33 | 34 | from override_storage import override_storage 35 | from override_storage.storage import LocMemStorage 36 | 37 | from .models import SimpleModel 38 | 39 | class OverrideStorageTestCase(TestCase): 40 | 41 | def test_context_manager(self): 42 | with override_storage(): 43 | # By default, all files saved to in memory cache. 44 | obj = SimpleModel() 45 | obj.upload_file.save('test.txt', ContentFile('content')) 46 | 47 | # Get your file back! 48 | content = obj.upload_file.read() 49 | 50 | @override_storage(storage=LocMemStorage()) 51 | def test_method_decorator(self): 52 | # You can also specify to replace all storage backends with a 53 | # storage instance of your choosing. Depending on the storage type, 54 | # this could mean all writes will persist for the life of the 55 | # instance. This does not really matter if you wanted to pass in a 56 | # FileSystemStorage instance as those writes will be persisted 57 | # regardless. 58 | ... 59 | 60 | @override_storage(storage=LocMemStorage) 61 | def test_method_decorator(self): 62 | # Passing in a class will create a new instance for every test. 63 | 64 | @override_storage() 65 | def test_method_decorator(self): 66 | # Used as a method decorator. 67 | ... 68 | 69 | 70 | @override_storage() 71 | class OverrideStorageClassTestCase(TestCase): 72 | # You can also wrap classes. 73 | ... 74 | 75 | 76 | It can also be used globally through a custom test runner. This can be achieved 77 | by setting the ``TEST_RUNNER`` setting in your settings file or however else 78 | you may choose to define the Django test runner. 79 | 80 | **Warning** 81 | 82 | ``TEST_RUNNER`` only sets up the replacement storage once at the start of the 83 | tests as there are no hooks into the ``setUp`` / ``tearDown`` methods of the 84 | test class. Using ``override_storage.LocMemStorageDiscoverRunner`` will share a 85 | single in memory cache across all tests. While this shouldn't affect your 86 | tests, if you write a lot of big files, you may run out of memory. 87 | 88 | .. code-block:: python 89 | 90 | TEST_RUNNER = 'override_storage.LocMemStorageDiscoverRunner' 91 | 92 | 93 | Storage information 94 | ------------------- 95 | 96 | Like ``override_storage``, ``locmem_stats_override_storage`` patches all 97 | ``FileField`` fields to store the contents of the file in an in-memory cache 98 | and returns the fields to their previous storages when leaving its context. 99 | 100 | In addition to the normal functionality, it returns an object with information 101 | about the calls to the ``_open`` and ``_save`` methods of the test storage. In 102 | general it records which fields have had files read from or written to them and 103 | the names of the files are recorded. 104 | 105 | .. code-block:: python 106 | 107 | from django.core.files.base import ContentFile 108 | from django.test import TestCase 109 | 110 | from override_storage import locmem_stats_override_storage 111 | 112 | from .models import SimpleModel 113 | 114 | class OverrideStorageTestCase(TestCase): 115 | 116 | def test_context_manager(self): 117 | with locmem_stats_override_storage() as storage_stats: 118 | # All files saved to in memory cache. 119 | obj = SimpleModel() 120 | obj.upload_file.save('test.txt', ContentFile('content')) 121 | 122 | # Check how many files have been saved 123 | storage_stats.save_cnt 124 | 125 | # Check which fields were read or saved 126 | storage_stats.fields_saved 127 | storage_stats.fields_read 128 | 129 | # Get a list of names, by field, which have been saved or read. 130 | storage_stats.reads_by_field 131 | storage_stats.saves_by_field 132 | 133 | # Get your file back! 134 | content = obj.upload_file.read() 135 | 136 | @locmem_stats_override_storage(name='storage_stats') 137 | def test_method_decorator(self, storage_stats): 138 | # access to storage stats by specifying `name` which is the name of 139 | # the kwarg to be used in the function signature. 140 | ... 141 | 142 | 143 | @locmem_stats_override_storage(name='storage_stats') 144 | class OverrideStorageClassTestCase(TestCase): 145 | storage_stats = None 146 | 147 | # access to storage stats by specifying attr_name 148 | ... 149 | -------------------------------------------------------------------------------- /override_storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .runner import LocMemStorageDiscoverRunner, StorageRunnerMixin 2 | from .storage import LocMemStorage 3 | from .utils import ( 4 | TestStorageError, StatsTestStorageError, locmem_stats_override_storage, override_storage, 5 | ) 6 | -------------------------------------------------------------------------------- /override_storage/__version__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 3, 2) 2 | 3 | __version__ = '.'.join(map(str, VERSION)) 4 | -------------------------------------------------------------------------------- /override_storage/runner.py: -------------------------------------------------------------------------------- 1 | from django.test.runner import DiscoverRunner 2 | 3 | from .storage import LocMemStorage 4 | from .utils import StorageTestMixin 5 | 6 | 7 | class StorageRunnerMixin(StorageTestMixin): 8 | 9 | def setup_test_environment(self): 10 | super(StorageRunnerMixin, self).setup_test_environment() 11 | self.setup_storage() 12 | 13 | def teardown_test_environment(self): 14 | super(StorageRunnerMixin, self).teardown_test_environment() 15 | self.teardown_storage() 16 | 17 | 18 | class LocMemStorageDiscoverRunner(StorageRunnerMixin, DiscoverRunner): 19 | 20 | storage = LocMemStorage() 21 | -------------------------------------------------------------------------------- /override_storage/storage.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from threading import RLock 3 | from urllib.parse import urljoin 4 | 5 | from django.conf import settings 6 | from django.core.files.base import ContentFile 7 | from django.core.files.storage import Storage 8 | from django.utils.deconstruct import deconstructible 9 | from django.utils.encoding import filepath_to_uri 10 | from django.utils.functional import cached_property 11 | from django.utils.timezone import now 12 | 13 | 14 | FakeContent = namedtuple('FakeContent', ['content', 'time']) 15 | 16 | 17 | @deconstructible 18 | class LocMemStorage(Storage): 19 | 20 | def __init__(self, base_url=None, cache_params=None): 21 | self.cache = {} 22 | self._lock = RLock() 23 | self._base_url = base_url 24 | 25 | def _open(self, name, mode='rb'): 26 | if 'w' in mode: 27 | raise NotImplementedError("This test backend doesn't support opening a file for writing.") 28 | with self._lock: 29 | return ContentFile(self.cache.get(name).content) 30 | 31 | def _save(self, name, content): 32 | # Make sure that the cache stores the file as bytes, like it would be 33 | # on disk. 34 | content = content.read() 35 | if not isinstance(content, (bytes, bytearray)): 36 | content = content.encode() 37 | with self._lock: 38 | while name in self.cache: 39 | name = self.get_available_name(name) 40 | self.cache[name] = FakeContent(content, now()) 41 | return name 42 | 43 | def _delete(self, name): 44 | # FileSystemStorage doesn't raise an error if the file doesn't exist. 45 | try: 46 | del self.cache[name] 47 | except KeyError: 48 | pass 49 | 50 | @cached_property 51 | def base_url(self): 52 | if self._base_url is not None: 53 | if not self._base_url.endswith('/'): 54 | self._base_url += '/' 55 | return self._base_url 56 | return settings.MEDIA_URL 57 | 58 | def path(self, name): 59 | """ 60 | Return a local filesystem path where the file can be retrieved using 61 | Python's built-in open() function. Storage systems that can't be 62 | accessed using open() should *not* implement this method. 63 | """ 64 | raise NotImplementedError("This backend doesn't support absolute paths.") 65 | 66 | def delete(self, name): 67 | """ 68 | Delete the specified file from the storage system. 69 | """ 70 | with self._lock: 71 | self._delete(name) 72 | 73 | def exists(self, name): 74 | """ 75 | Return True if a file referenced by the given name already exists in the 76 | storage system, or False if the name is available for a new file. 77 | """ 78 | return name in self.cache 79 | 80 | def listdir(self, path): 81 | """ 82 | List the contents of the specified path. Return a 2-tuple of lists: 83 | the first item being directories, the second item being files. 84 | """ 85 | raise NotImplementedError('subclasses of Storage must provide a listdir() method') 86 | 87 | def size(self, name): 88 | """ 89 | Return the total size, in bytes, of the file specified by name. 90 | """ 91 | return len(self.cache.get(name).content) 92 | 93 | def url(self, name): 94 | """ 95 | Return an absolute URL where the file's contents can be accessed 96 | directly by a Web browser. 97 | """ 98 | if self.base_url is None: 99 | raise ValueError("This file is not accessible via a URL.") 100 | url = filepath_to_uri(name) 101 | if url is not None: 102 | url = url.lstrip('/') 103 | return urljoin(self.base_url, url) 104 | 105 | def get_accessed_time(self, name): 106 | """ 107 | Return the last accessed time (as a datetime) of the file specified by 108 | name. The datetime will be timezone-aware if USE_TZ=True. 109 | """ 110 | return self.cache.get(name).time 111 | 112 | def get_created_time(self, name): 113 | """ 114 | Return the creation time (as a datetime) of the file specified by name. 115 | The datetime will be timezone-aware if USE_TZ=True. 116 | """ 117 | return self.cache.get(name).time 118 | 119 | def get_modified_time(self, name): 120 | """ 121 | Return the last modified time (as a datetime) of the file specified by 122 | name. The datetime will be timezone-aware if USE_TZ=True. 123 | """ 124 | return self.cache.get(name).time 125 | 126 | 127 | class StatsLocMemStorage(LocMemStorage): 128 | def __init__(self, field, stats, cache_params=None): 129 | self.stats = stats 130 | self.field = field 131 | super(StatsLocMemStorage, self).__init__(cache_params) 132 | 133 | def log_read(self, name): 134 | self.stats.log_read(self.field, name) 135 | 136 | def log_save(self, name): 137 | self.stats.log_save(self.field, name) 138 | 139 | def _open(self, name, mode='rb'): 140 | self.log_read(name) 141 | return super(StatsLocMemStorage, self)._open(name, mode) 142 | 143 | def open_no_log(self, name, mode='rb'): 144 | return super(StatsLocMemStorage, self)._open(name, mode) 145 | 146 | def _save(self, name, content): 147 | self.log_save(name) 148 | return super(StatsLocMemStorage, self)._save(name, content) 149 | 150 | def log_delete(self, name): 151 | self.stats.log_delete(self.field, name) 152 | 153 | def _delete(self, name): 154 | self.log_delete(name) 155 | return super(StatsLocMemStorage, self)._delete(name) 156 | -------------------------------------------------------------------------------- /override_storage/utils.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from collections import defaultdict 3 | 4 | from django.apps import apps 5 | from django.db.models import FileField 6 | from django.utils.functional import cached_property 7 | from django.test.utils import TestContextDecorator 8 | 9 | from .storage import LocMemStorage, StatsLocMemStorage 10 | 11 | 12 | class TestStorageError(Exception): 13 | pass 14 | 15 | 16 | class StatsTestStorageError(Exception): 17 | pass 18 | 19 | 20 | class Stats(object): 21 | 22 | read_cnt = 0 23 | save_cnt = 0 24 | delete_cnt = 0 25 | 26 | def get_full_field_name(self, field): 27 | meta = field.model._meta 28 | return (meta.app_label, meta.model_name, field.name) 29 | 30 | @cached_property 31 | def reads_by_field(self): 32 | return defaultdict(list) 33 | 34 | @cached_property 35 | def saves_by_field(self): 36 | return defaultdict(list) 37 | 38 | def log_read(self, field, fname): 39 | self.read_cnt += 1 40 | self.reads_by_field[self.get_full_field_name(field)] = fname 41 | 42 | def log_save(self, field, fname): 43 | self.save_cnt += 1 44 | self.saves_by_field[self.get_full_field_name(field)] = fname 45 | 46 | def log_delete(self, field, fname): 47 | self.delete_cnt += 1 48 | self.deletes_by_field[self.get_full_field_name(field)] = fname 49 | 50 | @cached_property 51 | def deletes_by_field(self): 52 | return defaultdict(list) 53 | 54 | @property 55 | def fields_delete(self): 56 | return list(self.deletes_by_field) 57 | 58 | def _get_content_file(self, app_label, model_name, field_name, fname): 59 | try: 60 | saved_files = self.saves_by_field[(app_label, model_name, field_name)] 61 | except KeyError: 62 | raise StatsTestStorageError( 63 | "{}.{}.{} has not been written to yet so there is nothing to read.".format( 64 | app_label, model_name, field_name)) 65 | 66 | if fname not in saved_files: 67 | raise StatsTestStorageError( 68 | "{}.{}.{} has not had a file named '{}' written to it. " 69 | "Be careful - the storage engine may have added some random " 70 | "characters to the name before attempting to write.".format( 71 | app_label, model_name, field_name, fname)) 72 | 73 | field = apps.get_model(app_label, model_name)._meta.get_field(field_name) 74 | return field.storage.open_no_log(fname) 75 | 76 | def get_content_file(self, field_key, fname): 77 | return self._get_content_file(field_key[0], field_key[1], field_key[2], fname) 78 | 79 | @property 80 | def fields_read(self): 81 | return list(self.reads_by_field) 82 | 83 | @property 84 | def fields_saved(self): 85 | return list(self.saves_by_field) 86 | 87 | 88 | class StorageTestMixin(object): 89 | 90 | storage = None 91 | storage_callable = None 92 | storage_per_field = False 93 | 94 | @cached_property 95 | def storage_stack(self): 96 | return [] 97 | 98 | @property 99 | def original_storages(self): 100 | return self.storage_stack[0] 101 | 102 | previous_storages = None 103 | 104 | def push_storage_stack(self): 105 | self.previous_storages = {} 106 | self.storage_stack.append(self.previous_storages) 107 | return self.previous_storages 108 | 109 | def pop_storage_stack(self): 110 | popped_storages = self.storage_stack.pop() 111 | try: 112 | self.previous_storages = self.storage_stack[-1] 113 | except IndexError: 114 | self.previous_storages = None 115 | return popped_storages 116 | 117 | def get_field_hash(self, field): 118 | # GH#8: 119 | # In Django < 3.2, instances of fields that appear on multiple 120 | # models via inheritance do not have unique hashes from __hash__ 121 | # for each model they eventually appear on. This causes the 122 | # dictionary value in previous_storages to be overwritten when 123 | # the same field is used on multiple models via inheritance as 124 | # __hash__ is the same. 125 | # This hash value implementation is taken from django 3.2 126 | return hash(( 127 | field.creation_counter, 128 | field.model._meta.app_label if hasattr(field, 'model') else None, 129 | field.model._meta.model_name if hasattr(field, 'model') else None, 130 | )) 131 | 132 | @cached_property 133 | def filefields(self): 134 | """ 135 | Return list of fields which are a FileField or subclass. 136 | """ 137 | filefields = [] 138 | for model in apps.get_models(): 139 | filefields.extend([f for f in model._meta.fields if isinstance(f, FileField)]) 140 | 141 | return filefields 142 | 143 | def get_storage_kwargs(self, field): 144 | if self.storage_kwargs: 145 | return self.storage_kwargs 146 | return {} 147 | 148 | def get_storage_from_callable(self, field): 149 | return self.storage_callable(**self.get_storage_kwargs(field)) 150 | 151 | def get_storage(self, field): 152 | """ 153 | This implementation returns an instance of a storage enigne. 154 | """ 155 | if self.storage is not None: 156 | return self.storage 157 | return self.get_storage_from_callable(field) 158 | 159 | def set_storage(self, field): 160 | if not hasattr(field, '_original_storage'): 161 | # Set an attribute on the field so that other StorageTestMixin 162 | # classes can filter on the original storage class (in a custom 163 | # `filefields` implementation), if they want to. 164 | field._original_storage = field.storage 165 | field.storage = self.get_storage(field) 166 | 167 | def setup_storage(self): 168 | """Save existing FileField storages and patch them with test instance(s).""" 169 | 170 | previous_storages = self.push_storage_stack() 171 | for field in self.filefields: 172 | if self.get_field_hash(field) in previous_storages: 173 | # Proxy models share field instances across multiple objects 174 | # but we only want to replace their storage once. Replacing 175 | # the storage multiple times results in losing track of what 176 | # the original storage was previously and breaks restoring the 177 | # field to its original storage. 178 | continue 179 | previous_storages[self.get_field_hash(field)] = ( 180 | field, field.storage 181 | ) 182 | self.set_storage(field) 183 | 184 | def teardown_storage(self): 185 | try: 186 | previous_storages = self.pop_storage_stack() 187 | except IndexError: 188 | return 189 | for field, original_storage in previous_storages.values(): 190 | field.storage = original_storage 191 | 192 | 193 | class StatsStorageTestMixin(StorageTestMixin): 194 | """ 195 | Collecting the statistics requires the storage engine knowning for which 196 | field it is saving the file. As this the storage engine doesn't normally 197 | have that information, using this class requires special storage engines. 198 | """ 199 | stats_cls = None 200 | 201 | def get_stats_cls_kwargs(self): 202 | return {} 203 | 204 | def _create_stats_obj(self): 205 | """ 206 | Create the stats object. 207 | 208 | There should only be one stats object created per setUp/tearDown cycle. 209 | In other words, only call this method in setup_storage() and use 210 | get the stats object via the stats_obj attribute. 211 | """ 212 | self.stats_obj = self.stats_cls(**self.get_stats_cls_kwargs()) 213 | return self.stats_obj 214 | 215 | def get_stats_obj(self): 216 | return self.stats_obj 217 | 218 | def get_storage_kwargs(self, field): 219 | kwargs = super(StatsStorageTestMixin, self).get_storage_kwargs(field) 220 | kwargs.update({ 221 | 'stats': self.stats_obj, 222 | 'field': field, 223 | }) 224 | return kwargs 225 | 226 | def setup_storage(self): 227 | # Depending on how an instance from this class is setup, it is possible 228 | # that one instance will be used for all test. Creating a new `stats` 229 | # object on setup allows the stats to be reset between tests even if 230 | # this instance derived from the `StatsStorageTestMixin` persists 231 | # across all setup and teardowns. This is not a concern if you use a 232 | # helper fn to create a new `StatsStorageTestMixin` instance each 233 | # time (like in `locmem_stats_override_storage`). 234 | 235 | stats = self._create_stats_obj() 236 | super(StatsStorageTestMixin, self).setup_storage() 237 | return stats 238 | 239 | 240 | class StorageTestContextDecoratorBase(TestContextDecorator): 241 | def __init__(self, unused_arg=None, **kwargs): 242 | """Inits class with check for correct calling patterns. 243 | 244 | Subclasses should also declare unused_arg as their first argument. 245 | This defends against passing the function to be wrapped as the first 246 | arg to this init method rather than as the first argument to the 247 | resulting instance, which does act like a decorator. 248 | 249 | Failing to do this can result in undesired behaviour, such as not 250 | executing the wrapped function at all. 251 | 252 | eg. 253 | - Proper usage with parenthesis on the decorator: 254 | @subclass() 255 | def wrapped_fn(): 256 | pass 257 | 258 | - Incorrect usage without parenthesis. Exception will be raised: 259 | @subclass 260 | def wrapped_fn(): 261 | pass 262 | """ 263 | if unused_arg is not None: 264 | raise TestStorageError( 265 | 'Incorrect usage: Positional arguments, calling as a decorator ' 266 | 'without parenthesis and specifying the `unused_arg` keyword ' 267 | 'are not supported.') 268 | 269 | def enable(self): 270 | try: 271 | return self.setup_storage() 272 | except: 273 | self.teardown_storage() 274 | raise 275 | 276 | def disable(self): 277 | self.teardown_storage() 278 | 279 | 280 | class override_storage(StorageTestMixin, StorageTestContextDecoratorBase): 281 | 282 | attr_name = None 283 | kwarg_name = None 284 | 285 | def __init__(self, unused_arg=None, storage=None, 286 | storage_kwargs=None, storage_per_field=False, 287 | storage_cls_or_obj=None, storage_cls_kwargs=None): 288 | """Return an object to override the storage engines of FileFields. 289 | 290 | Instance can be used as a decorator or context manager. 291 | 292 | Args: 293 | unused_arg: If anything apart from None, an error will be raised. 294 | Defends against accidental misuse as a decorator. 295 | storage (optional): storage instance or callable that returns a 296 | storage instance. LocMemStorage by default. 297 | storage_kwargs (optional): kwargs passed to storage if storage is 298 | callable. 299 | storage_per_field (optional): When storage is callable, if False 300 | (default), use one result from the callable to replace all 301 | FileField fields. If True and storage is callable, replace 302 | every FileField with a different call to the storage callable. 303 | """ 304 | super(override_storage, self).__init__(unused_arg) 305 | 306 | if storage_cls_or_obj is not None: 307 | warnings.warn( 308 | 'storage_cls_or_obj is deprecated. Use storage instead.', 309 | DeprecationWarning) 310 | if storage is not None: 311 | raise TestStorageError( 312 | 'storage_cls_or_obj is deprecated and was specified with ' 313 | 'as well as storage. Only use storage.') 314 | storage = storage_cls_or_obj 315 | 316 | if storage_cls_kwargs is not None: 317 | warnings.warn( 318 | 'storage_cls_kwargs is deprecated. Use storage_kwargs instead.', 319 | DeprecationWarning) 320 | if storage_kwargs is not None: 321 | raise TestStorageError( 322 | 'storage_cls_kwargs is deprecated and was specified with ' 323 | 'as well as storage_kwargs. Only use storage_kwargs.') 324 | storage_kwargs = storage_cls_kwargs 325 | 326 | if storage is None: 327 | self.storage_callable = LocMemStorage 328 | else: 329 | if hasattr(storage, '__call__'): 330 | self.storage_callable = storage 331 | else: 332 | self.storage = storage 333 | 334 | self.storage_kwargs = storage_kwargs 335 | self.storage_per_field = storage_per_field 336 | 337 | def setup_storage(self): 338 | """Save existing FileField storages and patch them with test instance(s). 339 | 340 | If storage_per_field is False (default) this function will create a 341 | single instance here and assign it to self.storage to be used for all 342 | filefields. 343 | If storage_per_field is True, an independent storage instance will be 344 | used for each FileField . 345 | """ 346 | if self.storage_callable is not None and not self.storage_per_field: 347 | self.storage = self.get_storage_from_callable(field=None) 348 | super(override_storage, self).setup_storage() 349 | 350 | 351 | class stats_override_storage(StatsStorageTestMixin, StorageTestContextDecoratorBase): 352 | 353 | stats_cls = Stats 354 | attr_name = None 355 | kwarg_name = None 356 | 357 | def __init__(self, unused_arg=None, storage=None, storage_kwargs=None, 358 | name=None): 359 | 360 | super(stats_override_storage, self).__init__(unused_arg) 361 | 362 | if storage is None: 363 | self.storage_callable = StatsLocMemStorage 364 | else: 365 | self.storage_callable = storage 366 | 367 | self.storage_kwargs = storage_kwargs 368 | 369 | if name is not None: 370 | self.attr_name = name 371 | self.kwarg_name = name 372 | 373 | 374 | def locmem_stats_override_storage(unused_arg=None, name=None): 375 | return stats_override_storage(unused_arg, name=name) 376 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | mock;python_version<"3.6" 3 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | if __name__ == "__main__": 10 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 11 | django.setup() 12 | TestRunner = get_runner(settings) 13 | test_runner = TestRunner() 14 | failures = test_runner.run_tests(["tests"]) 15 | sys.exit(bool(failures)) 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Note: To use the 'upload' functionality of this file, you must: 5 | # $ pip install twine 6 | 7 | import io 8 | import os 9 | import sys 10 | from shutil import rmtree 11 | 12 | from setuptools import find_packages, setup, Command 13 | 14 | # Package meta-data. 15 | NAME = 'django-override-storage' 16 | DESCRIPTION = 'Django test helpers to manage file storage side effects.' 17 | URL = 'https://github.com/danifus/django-override-storage' 18 | EMAIL = 'daniel.hillier@gmail.com' 19 | AUTHOR = 'Daniel Hillier' 20 | REQUIRES_PYTHON = '>=2.7, !=3.0.*, !=3.1.*, !=3.2.*' 21 | VERSION = None 22 | 23 | # What packages are required for this module to be executed? 24 | REQUIRED = [] 25 | 26 | # The rest you shouldn't have to touch too much :) 27 | # ------------------------------------------------ 28 | # Except, perhaps the License and Trove Classifiers! 29 | # If you do change the License, remember to change the Trove Classifier for that! 30 | 31 | here = os.path.abspath(os.path.dirname(__file__)) 32 | 33 | # Import the README and use it as the long-description. 34 | # Note: this will only work if 'README.rst' is present in your MANIFEST.in file! 35 | with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: 36 | long_description = '\n' + f.read() 37 | 38 | # Load the package's __version__.py module as a dictionary. 39 | about = {} 40 | if not VERSION: 41 | with open(os.path.join(here, 'override_storage', '__version__.py')) as f: 42 | exec(f.read(), about) 43 | else: 44 | about['__version__'] = VERSION 45 | 46 | 47 | class UploadCommand(Command): 48 | """Support setup.py upload.""" 49 | 50 | description = 'Build and publish the package.' 51 | user_options = [] 52 | 53 | @staticmethod 54 | def status(s): 55 | """Prints things in bold.""" 56 | print('\033[1m{0}\033[0m'.format(s)) 57 | 58 | def initialize_options(self): 59 | pass 60 | 61 | def finalize_options(self): 62 | pass 63 | 64 | def run(self): 65 | try: 66 | self.status('Removing previous builds…') 67 | rmtree(os.path.join(here, 'dist')) 68 | except OSError: 69 | pass 70 | 71 | self.status('Building Source and Wheel (universal) distribution…') 72 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 73 | 74 | self.status('Uploading the package to PyPi via Twine…') 75 | os.system('twine upload dist/*') 76 | 77 | self.status('Pushing git tags…') 78 | os.system('git tag v{0}'.format(about['__version__'])) 79 | os.system('git push --tags') 80 | 81 | sys.exit() 82 | 83 | 84 | # Where the magic happens: 85 | setup( 86 | name=NAME, 87 | version=about['__version__'], 88 | description=DESCRIPTION, 89 | long_description=long_description, 90 | author=AUTHOR, 91 | author_email=EMAIL, 92 | python_requires=REQUIRES_PYTHON, 93 | url=URL, 94 | packages=find_packages(exclude=('tests', 'docs')), 95 | install_requires=REQUIRED, 96 | include_package_data=True, 97 | license='MIT', 98 | classifiers=[ 99 | 'Development Status :: 3 - Alpha', 100 | 'Environment :: Web Environment', 101 | 'Framework :: Django', 102 | 'Framework :: Django :: 1.10', 103 | 'Framework :: Django :: 1.11', 104 | 'Framework :: Django :: 2.0', 105 | 'Framework :: Django :: 2.1', 106 | 'Framework :: Django :: 2.2', 107 | 'Framework :: Django :: 3.0', 108 | 'Framework :: Django :: 3.1', 109 | 'Framework :: Django :: 3.2', 110 | 'Intended Audience :: Developers', 111 | 'License :: OSI Approved :: MIT License', 112 | 'Operating System :: OS Independent', 113 | 'Programming Language :: Python', 114 | 'Programming Language :: Python :: 2', 115 | 'Programming Language :: Python :: 2.7', 116 | 'Programming Language :: Python :: 3', 117 | 'Programming Language :: Python :: 3.4', 118 | 'Programming Language :: Python :: 3.5', 119 | 'Programming Language :: Python :: 3.6', 120 | 'Programming Language :: Python :: 3.7', 121 | 'Programming Language :: Python :: 3.8', 122 | 'Programming Language :: Python :: 3.9', 123 | ], 124 | # $ setup.py publish support. 125 | cmdclass={ 126 | 'upload': UploadCommand, 127 | }, 128 | ) 129 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danifus/django-override-storage/54c3ba49d5d440277b653648c37fd915c21dff8a/tests/__init__.py -------------------------------------------------------------------------------- /tests/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 4 | 5 | import override_storage 6 | from override_storage import storage 7 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import FileSystemStorage 2 | from django.db import models 3 | 4 | 5 | class StrictFNameFileSystemStorage(FileSystemStorage): 6 | 7 | def exists(self, name): 8 | exists = super(StrictFNameFileSystemStorage, self).exists(name) 9 | if exists: 10 | raise Exception("File '{}' already exists.".format(name)) 11 | return False 12 | 13 | 14 | class SimpleModel(models.Model): 15 | 16 | upload_file = models.FileField(storage=StrictFNameFileSystemStorage()) 17 | 18 | 19 | class SimpleProxyModel(SimpleModel): 20 | """Proxy models share the same field instance as the parent model. This 21 | model ensures the tear down process restores the field to the original 22 | state even if a particular field instance is seen multiple times. 23 | """ 24 | 25 | class Meta: 26 | proxy = True 27 | 28 | 29 | class AbstractBaseModel(models.Model): 30 | 31 | upload_file = models.FileField(storage=StrictFNameFileSystemStorage()) 32 | 33 | class Meta: 34 | abstract = True 35 | 36 | 37 | class ChildModelA(AbstractBaseModel): 38 | pass 39 | 40 | 41 | class ChildModelB(AbstractBaseModel): 42 | pass 43 | 44 | 45 | class InheritedModel(models.Model): 46 | 47 | upload_file = models.FileField(storage=StrictFNameFileSystemStorage()) 48 | 49 | 50 | class InheritedChildA(InheritedModel): 51 | pass 52 | 53 | 54 | class InheritedChildB(InheritedModel): 55 | pass 56 | -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.files.base import ContentFile 4 | from django.test import TestCase 5 | from django.test.utils import override_settings 6 | 7 | from . import models 8 | from .models import SimpleModel, SimpleProxyModel 9 | from .context import override_storage 10 | 11 | try: 12 | from unittest import mock 13 | except ImportError: 14 | import mock 15 | 16 | 17 | original_storage = SimpleModel._meta.get_field('upload_file').storage 18 | 19 | 20 | @override_storage.override_storage() 21 | class OverrideStorageClassNoAttrTestCase(TestCase): 22 | 23 | def save_file(self, name, content): 24 | expected_path = original_storage.path(name) 25 | obj = SimpleModel() 26 | obj.upload_file.save(name, ContentFile(content)) 27 | return expected_path 28 | 29 | def test_class_decorator(self): 30 | expected_path = self.save_file('class_decorator.txt', 'class_decorator') 31 | self.assertFalse(os.path.exists(expected_path)) 32 | 33 | 34 | class OverrideStorageTestCase(TestCase): 35 | 36 | def save_file(self, name, content): 37 | expected_path = original_storage.path(name) 38 | obj = SimpleModel() 39 | obj.upload_file.save(name, ContentFile(content)) 40 | return expected_path 41 | 42 | def test_context_manager(self): 43 | with override_storage.override_storage(storage=override_storage.LocMemStorage()): 44 | expected_path = self.save_file('test_context_mngr.txt', 'context_mngr') 45 | self.assertFalse(os.path.exists(expected_path)) 46 | 47 | def test_specified_storage(self): 48 | storage = override_storage.LocMemStorage() 49 | upload_file_field = SimpleModel._meta.get_field('upload_file') 50 | original_storage = upload_file_field.storage 51 | with override_storage.override_storage(storage=storage): 52 | self.assertEqual(upload_file_field.storage, storage) 53 | self.assertEqual(upload_file_field.storage, original_storage) 54 | 55 | def test_file_saved(self): 56 | name = 'saved_file.txt' 57 | content = 'saved_file'.encode() 58 | obj = SimpleModel() 59 | with override_storage.override_storage(): 60 | obj.upload_file.save(name, ContentFile(content)) 61 | read_content = obj.upload_file.read() 62 | expected_path = original_storage.path(name) 63 | 64 | self.assertFalse(os.path.exists(expected_path)) 65 | self.assertEqual(content, read_content) 66 | 67 | def test_save_bytes_data(self): 68 | """save works with bytes objects with bytes which can't be decoded to 69 | ascii in both Python 2 and 3. 70 | """ 71 | name = 'saved_file.txt' 72 | content = b'saved_file \xff' 73 | obj = SimpleModel() 74 | with override_storage.override_storage(): 75 | obj.upload_file.save(name, ContentFile(content)) 76 | read_content = obj.upload_file.read() 77 | expected_path = original_storage.path(name) 78 | 79 | self.assertFalse(os.path.exists(expected_path)) 80 | self.assertEqual(content, read_content) 81 | 82 | @override_settings(MEDIA_URL="/media/") 83 | def test_default_url(self): 84 | name = 'saved_file.txt' 85 | content = 'saved_file'.encode() 86 | obj = SimpleModel() 87 | with override_storage.override_storage(): 88 | obj.upload_file.save(name, ContentFile(content)) 89 | url = obj.upload_file.url 90 | 91 | self.assertEqual(url, "/media/" + name) 92 | 93 | def test_base_url(self): 94 | name = 'saved_file.txt' 95 | content = 'saved_file'.encode() 96 | obj = SimpleModel() 97 | with override_storage.override_storage(storage_kwargs={"base_url": "/my_media/"}): 98 | obj.upload_file.save(name, ContentFile(content)) 99 | url = obj.upload_file.url 100 | self.assertEqual(url, "/my_media/" + name) 101 | 102 | def test_method_decorator_with_no_parens_raises_error(self): 103 | """Using override_storage fails as a decorator with no parens.""" 104 | with self.assertRaises(override_storage.TestStorageError): 105 | @override_storage.override_storage 106 | def fake_fn(): 107 | pass 108 | 109 | @override_storage.override_storage() 110 | def test_method_decorator_no_kwarg(self): 111 | expected_path = self.save_file('test_method_decorator.txt', 'method_decorator') 112 | self.assertFalse(os.path.exists(expected_path)) 113 | 114 | def test_nested_overrides(self): 115 | upload_file_field = SimpleModel._meta.get_field('upload_file') 116 | original_storage = upload_file_field.storage 117 | outer_storage = override_storage.LocMemStorage() 118 | inner_storage = override_storage.LocMemStorage() 119 | with override_storage.override_storage(storage=outer_storage): 120 | self.assertEqual(upload_file_field.storage, outer_storage) 121 | 122 | with override_storage.override_storage(storage=inner_storage): 123 | self.assertEqual(upload_file_field.storage, inner_storage) 124 | 125 | self.assertEqual(upload_file_field.storage, outer_storage) 126 | self.assertEqual(upload_file_field.storage, original_storage) 127 | 128 | def test_proxy_models(self): 129 | """Proxy models should not interfer with the override or tear down. 130 | 131 | Because proxy models have the same filefield instance as the parent 132 | model, there is a risk that a filefield storage will be overridden 133 | twice affecting the ability to restore it to its original storage. 134 | """ 135 | # Make sure that the tests actually have a proxy model present. 136 | self.assertTrue(SimpleProxyModel._meta.proxy) 137 | upload_file_field = SimpleModel._meta.get_field('upload_file') 138 | original_storage = upload_file_field.storage 139 | with override_storage.override_storage(): 140 | self.assertNotEqual(upload_file_field.storage, original_storage) 141 | self.assertEqual(upload_file_field.storage, original_storage) 142 | 143 | def test_abstract_models(self): 144 | """Abstract models should each have their inherited fields swapped. 145 | """ 146 | upload_file_field_a = models.ChildModelA._meta.get_field('upload_file') 147 | upload_file_field_b = models.ChildModelB._meta.get_field('upload_file') 148 | original_storage_a = upload_file_field_a.storage 149 | original_storage_b = upload_file_field_b.storage 150 | with override_storage.override_storage(): 151 | self.assertNotEqual(upload_file_field_a.storage, original_storage_a) 152 | self.assertNotEqual(upload_file_field_b.storage, original_storage_b) 153 | self.assertEqual(upload_file_field_a.storage, original_storage_a) 154 | self.assertEqual(upload_file_field_b.storage, original_storage_b) 155 | 156 | def test_concrete_inheritance_models(self): 157 | """Concrete inheritance models should each have their inherited fields swapped. 158 | """ 159 | upload_file_field_a = models.InheritedChildA._meta.get_field('upload_file') 160 | upload_file_field_b = models.InheritedChildB._meta.get_field('upload_file') 161 | original_storage_a = upload_file_field_a.storage 162 | original_storage_b = upload_file_field_b.storage 163 | with override_storage.override_storage(): 164 | self.assertNotEqual(upload_file_field_a.storage, original_storage_a) 165 | self.assertNotEqual(upload_file_field_b.storage, original_storage_b) 166 | self.assertEqual(upload_file_field_a.storage, original_storage_a) 167 | self.assertEqual(upload_file_field_b.storage, original_storage_b) 168 | 169 | def test_delete(self): 170 | """delete removes entry from cache.""" 171 | storage = override_storage.LocMemStorage() 172 | name = 'test_file' 173 | content = ContentFile('test') 174 | storage._save(name, content) 175 | 176 | self.assertTrue(name in storage.cache) 177 | storage.delete(name) 178 | self.assertFalse(name in storage.cache) 179 | 180 | def test_delete_missing_key(self): 181 | """delete asserts no error if the key is already not in the cache.""" 182 | storage = override_storage.LocMemStorage() 183 | name = 'test_file' 184 | self.assertFalse(name in storage.cache) 185 | storage.delete(name) 186 | 187 | 188 | class StatsOverrideStorageTestCase(TestCase): 189 | 190 | def save_file(self, name, content): 191 | expected_path = original_storage.path(name) 192 | obj = SimpleModel() 193 | obj.upload_file.save(name, ContentFile(content)) 194 | return expected_path 195 | 196 | def test_context_manager(self): 197 | with override_storage.locmem_stats_override_storage(): 198 | expected_path = self.save_file('test_context_mngr.txt', 'context_mngr') 199 | self.assertFalse(os.path.exists(expected_path)) 200 | 201 | @override_storage.locmem_stats_override_storage(name='override_storage_field') 202 | def test_method_decorator(self, override_storage_field): 203 | self.assertEqual(override_storage_field.save_cnt, 0) 204 | expected_path = self.save_file('test_method_decorator.txt', 'method_decorator') 205 | self.assertFalse(os.path.exists(expected_path)) 206 | self.assertEqual(override_storage_field.save_cnt, 1) 207 | 208 | @override_storage.locmem_stats_override_storage() 209 | def test_method_decorator_no_kwarg(self): 210 | expected_path = self.save_file('test_method_decorator.txt', 'method_decorator') 211 | self.assertFalse(os.path.exists(expected_path)) 212 | 213 | def test_method_decorator_with_no_parens_raises_error(self): 214 | """Using locmem_stats_override_storage fails as a decorator with no parens.""" 215 | with self.assertRaises(override_storage.TestStorageError): 216 | @override_storage.locmem_stats_override_storage 217 | def fake_fn(): 218 | pass 219 | 220 | def test_nested_overrides(self): 221 | 222 | upload_file_field = SimpleModel._meta.get_field('upload_file') 223 | original_storage = upload_file_field.storage 224 | with override_storage.locmem_stats_override_storage() as storage1: 225 | nested_1_storage = upload_file_field.storage 226 | self.assertNotEqual(original_storage, nested_1_storage) 227 | self.assertEqual(storage1.save_cnt, 0) 228 | self.save_file('save_outer_1', 'save_outer_1') 229 | self.save_file('save_outer_2', 'save_outer_2') 230 | self.assertEqual(storage1.save_cnt, 2) 231 | with override_storage.locmem_stats_override_storage() as storage2: 232 | nested_2_storage = upload_file_field.storage 233 | self.assertNotEqual(nested_1_storage, nested_2_storage) 234 | self.assertEqual(storage1.save_cnt, 2) 235 | self.assertEqual(storage2.save_cnt, 0) 236 | self.save_file('save_inner_1', 'save_inner_1') 237 | self.assertEqual(storage2.save_cnt, 1) 238 | self.assertEqual(storage1.save_cnt, 2) 239 | 240 | self.assertEqual(upload_file_field.storage, nested_1_storage) 241 | self.assertEqual(upload_file_field.storage, original_storage) 242 | 243 | def test_read_and_save_cnt(self): 244 | 245 | with override_storage.locmem_stats_override_storage() as storage: 246 | name = 'file.txt' 247 | content = 'file content'.encode() 248 | self.assertEqual(storage.read_cnt, 0) 249 | self.assertEqual(storage.save_cnt, 0) 250 | 251 | obj = SimpleModel() 252 | obj.upload_file.save(name, ContentFile(content)) 253 | 254 | self.assertEqual(storage.save_cnt, 1) 255 | self.assertEqual(storage.read_cnt, 0) 256 | 257 | read_content = obj.upload_file.read() 258 | 259 | self.assertEqual(storage.read_cnt, 1) 260 | self.assertEqual(content, read_content) 261 | 262 | def test_get_file_contents(self): 263 | with override_storage.locmem_stats_override_storage() as storage: 264 | name = 'file.txt' 265 | content = 'file content'.encode() 266 | 267 | self.assertEqual(storage.read_cnt, 0) 268 | 269 | obj = SimpleModel() 270 | obj.upload_file.save(name, ContentFile(content)) 271 | 272 | field_key, name_key = list(storage.saves_by_field.items())[0] 273 | read_content = storage.get_content_file(field_key, name_key) 274 | self.assertEqual(content, read_content.read()) 275 | 276 | # make sure accessing via stats doesn't increment the stats. 277 | self.assertEqual(storage.read_cnt, 0) 278 | 279 | def test_get_file_contents_not_written(self): 280 | fname = 'wrong_test.txt' 281 | upload_file_field = SimpleModel._meta.get_field('upload_file') 282 | with override_storage.locmem_stats_override_storage() as storage: 283 | field_key = storage.get_full_field_name(upload_file_field) 284 | with self.assertRaises(override_storage.StatsTestStorageError): 285 | storage.get_content_file(field_key, fname) 286 | 287 | obj = SimpleModel() 288 | obj.upload_file.save('test.txt', ContentFile('content')) 289 | with self.assertRaises(override_storage.StatsTestStorageError): 290 | storage.get_content_file(field_key, fname) 291 | 292 | def test_delete(self): 293 | """delete removes entry from cache.""" 294 | # TODO: initialise a StatsLocMemStorage 295 | with override_storage.locmem_stats_override_storage() as stats: 296 | name = 'test_file' 297 | obj = SimpleModel() 298 | obj.upload_file.save(name, ContentFile('content')) 299 | obj.upload_file.delete() 300 | self.assertEqual(stats.delete_cnt, 1) 301 | 302 | def test_delete_missing_key(self): 303 | """delete asserts no error if the key is already not in the cache. 304 | 305 | Still adds to the delete count. 306 | """ 307 | with override_storage.locmem_stats_override_storage() as stats: 308 | name = 'test_file' 309 | obj = SimpleModel() 310 | obj.upload_file.save(name, ContentFile('content')) 311 | obj.upload_file.delete() 312 | self.assertEqual(stats.delete_cnt, 1) 313 | obj.upload_file.delete() 314 | self.assertEqual(stats.delete_cnt, 1) 315 | 316 | 317 | @override_storage.locmem_stats_override_storage(name='override_storage_field') 318 | class StatsOverrideStorageClassTestCase(TestCase): 319 | 320 | override_storage_field = None 321 | 322 | def save_file(self, name, content): 323 | expected_path = original_storage.path(name) 324 | obj = SimpleModel() 325 | obj.upload_file.save(name, ContentFile(content)) 326 | return expected_path 327 | 328 | def test_class_decorator(self): 329 | expected_path = self.save_file('class_decorator.txt', 'class_decorator') 330 | self.assertFalse(os.path.exists(expected_path)) 331 | 332 | def test_class_decorator_attr_name(self): 333 | self.assertEqual(self.override_storage_field.save_cnt, 0) 334 | self.save_file('class_decorator.txt', 'class_decorator') 335 | self.assertEqual(self.override_storage_field.save_cnt, 1) 336 | 337 | 338 | @override_storage.locmem_stats_override_storage() 339 | class StatsOverrideStorageClassNoAttrTestCase(TestCase): 340 | 341 | def save_file(self, name, content): 342 | expected_path = original_storage.path(name) 343 | obj = SimpleModel() 344 | obj.upload_file.save(name, ContentFile(content)) 345 | return expected_path 346 | 347 | def test_class_decorator(self): 348 | expected_path = self.save_file('class_decorator.txt', 'class_decorator') 349 | self.assertFalse(os.path.exists(expected_path)) 350 | 351 | 352 | class TearDownTestCase(TestCase): 353 | def test_teardown(self): 354 | class WeirdError(Exception): 355 | pass 356 | with mock.patch('override_storage.override_storage.setup_storage', side_effect=WeirdError()): 357 | with self.assertRaises(WeirdError): 358 | # Make sure it raises the real error and does the tear down 359 | # without failing. 360 | with override_storage.override_storage(): 361 | pass 362 | 363 | 364 | @override_storage.override_storage() 365 | class NoPersistenceTestCase(TestCase): 366 | 367 | def assertNoRecords(self): 368 | test_storage = SimpleModel._meta.get_field('upload_file').storage 369 | self.assertEqual(len(test_storage.cache), 0) 370 | 371 | def test_persistence_1(self): 372 | self.assertNoRecords() 373 | obj = SimpleModel() 374 | obj.upload_file.save('test_1', ContentFile('content')) 375 | 376 | def test_persistence_2(self): 377 | self.assertNoRecords() 378 | obj = SimpleModel() 379 | obj.upload_file.save('test_2', ContentFile('content')) 380 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | HERE = os.path.dirname(__file__) 4 | 5 | SECRET_KEY = 'fake-key' 6 | INSTALLED_APPS = [ 7 | 'tests', 8 | ] 9 | 10 | # Ideally nothing will be written here... 11 | MEDIA_ROOT = os.path.join(HERE, 'media') 12 | 13 | DATABASES = { 14 | 'default': { 15 | 'ENGINE': 'django.db.backends.sqlite3', 16 | 'NAME': os.path.join(HERE, 'test.db'), 17 | } 18 | } 19 | 20 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36,37,38,39}-django22, 4 | py{36,37,38,39}-django31, 5 | py{36,37,38,39}-django32, 6 | 7 | [testenv] 8 | commands = python runtests.py 9 | deps = 10 | django22: django~=2.2.17 # first patch release with Python 3.9 support 11 | django31: django~=3.1.3 # first patch release with Python 3.9 support 12 | django32: django~=3.2.0 13 | --------------------------------------------------------------------------------