diff --git a/.travis.yml b/.travis.yml index 9ad1cbb..9521ee1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python sudo: required python: - - '3.5' - '3.6' - '3.7' - '3.8' @@ -13,7 +12,6 @@ install: # to fail. Linting and static analysis only happens on the Python 3.8 builds. - pip install --upgrade -r lint-requirements.txt || true env: - - DJANGO=1.11 - DJANGO=2.2 - DJANGO=3.0 before_script: @@ -23,17 +21,13 @@ script: - 'if [[ $(python --version) == "Python 3.8."* ]]; then flake8; fi' - 'if [[ $(python --version) == "Python 3.8."* ]]; then black --check .; fi' - 'if [[ $(python --version) == "Python 3.8."* ]]; then sorti --check .; fi' - - 'if [[ $(python --version) == "Python 3.8."* && "$DJANGO" != "1.11" ]]; then pip install . && mypy .; fi' + - 'if [[ $(python --version) == "Python 3.8."* ]]; then pip install . && mypy .; fi' - 'if [[ $TRAVIS_REPO_SLUG != "antonagestam/collectfast" ]]; then export SKIP_LIVE_TESTS=true; fi' - coverage run --source collectfast -m pytest after_script: - coveralls matrix: exclude: - - env: DJANGO=3.0 - python: '3.5' - - env: DJANGO=1.11 - python: '3.8' - env: DJANGO=2.2 python: '3.8' notifications: diff --git a/Makefile b/Makefile index 86c9469..6446265 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,18 @@ test-skip-live: test-coverage: . storage-credentials && coverage run --source collectfast -m pytest -distribute: - pip install --upgrade wheel twine setuptools - python setup.py sdist bdist_wheel - twine upload dist/* +clean: + rm -rf Collectfast.egg-info __pycache__ build dist + +build: clean + python3 -m pip install --upgrade wheel twine setuptools + python3 setup.py sdist bdist_wheel + +distribute: build + python3 -m twine upload dist/* + +test-distribute: build + python3 -m twine upload --repository-url https://test.pypi.org/legacy/ dist/* lint: flake8 diff --git a/README.md b/README.md new file mode 100644 index 0000000..69dbb5d --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# Collectfast + +A faster collectstatic command. + +[![Build Status](https://api.travis-ci.org/antonagestam/collectfast.svg?branch=master)](https://travis-ci.org/antonagestam/collectfast) +[![Coverage Status](https://coveralls.io/repos/github/antonagestam/collectfast/badge.svg?branch=master)](https://coveralls.io/github/antonagestam/collectfast?branch=master) + +**Features** + +- Efficiently decide what files to upload using cached checksums +- Parallel file uploads + +**Supported Storage Backends** + +- `storages.backends.s3boto3.S3Boto3Storage` +- `storages.backends.gcloud.GoogleCloudStorage` +- `django.core.files.storage.FileSystemStorage` + +Running Django's `collectstatic` command can become painfully slow as more and +more files are added to a project, especially when heavy libraries such as +jQuery UI are included in source code. Collectfast customizes the builtin +`collectstatic` command, adding different optimizations to make uploading large +amounts of files much faster. + + +## Installation + +Install the app using pip: + +```bash +$ python3 -m pip install Collectfast +``` + +Make sure you have this in your settings file and add `'collectfast'` to your +`INSTALLED_APPS`, before `'django.contrib.staticfiles'`: + +```python +STATICFILES_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" +COLLECTFAST_STRATEGY = "collectfast.strategies.boto3.Boto3Strategy" +INSTALLED_APPS = ( + # ... + 'collectfast', +) +``` + +**Note:** `'collectfast'` must come before `'django.contrib.staticfiles'` in +`INSTALLED_APPS`. + +**Note:** The boto strategy will set `preload_metadata` on the remote storage +to `True`, see [#30][issue-30]. + +[issue-30]: https://github.com/antonagestam/collectfast/issues/30 + +##### Upload Strategies + +Collectfast Strategy|Storage Backend +---|--- +collectfast.strategies.boto3.Boto3Strategy|storages.backends.s3boto3.S3Boto3Storage +collectfast.strategies.gcloud.GoogleCloudStrategy|storages.backends.gcloud.GoogleCloudStorage +collectfast.strategies.filesystem.FileSystemStrategy|django.core.files.storage.FileSystemStorage + +Custom strategies can also be made for backends not listed above by +implementing the `collectfast.strategies.Strategy` ABC. + + +## Usage + +Collectfast overrides Django's builtin `collectstatic` command so just run +`python manage.py collectstatic` as normal. + +You can disable Collectfast by using the `--disable-collectfast` option or by +setting `COLLECTFAST_ENABLED = False` in your settings file. + +### Setting Up a Dedicated Cache Backend + +It's recommended to setup a dedicated cache backend for Collectfast. Every time +Collectfast does not find a lookup for a file in the cache it will trigger a +lookup to the storage backend, so it's recommended to have a fairly high +`TIMEOUT` setting. + +Configure your dedicated cache with the `COLLECTFAST_CACHE` setting: + +```python +CACHES = { + 'default': { + # Your default cache + }, + 'collectfast': { + # Your dedicated Collectfast cache + }, +} + +COLLECTFAST_CACHE = 'collectfast' +``` + +If `COLLECTFAST_CACHE` isn't set, the `default` cache will be used. + +**Note:** Collectfast will never clean the cache of obsolete files. To clean +out the entire cache, use `cache.clear()`. [See docs for Django's cache +framework][django-cache]. + +**Note:** We recommend you to set the `MAX_ENTRIES` setting if you have more +than 300 static files, see [#47][issue-47]. + +[django-cache]: https://docs.djangoproject.com/en/stable/topics/cache/ +[issue-47]: https://github.com/antonagestam/collectfast/issues/47 + +### Enable Parallel Uploads + +The parallelization feature enables parallel file uploads using Python's +multiprocessing module. Enable it by setting the `COLLECTFAST_THREADS` setting. + +To enable parallel uploads, a dedicated cache backend must be setup and it must +use a backend that is thread-safe, i.e. something other than Django's default +LocMemCache. + +```python +COLLECTFAST_THREADS = 20 +``` + + +## Debugging + +By default, Collectfast will suppress any exceptions that happens when copying +and let Django's `collectstatic` handle it. To debug those suppressed errors +you can set `COLLECTFAST_DEBUG = True` in your Django settings file. + + +## Contribution + +Please feel free to contribute by using issues and pull requests. Discussion is +open and welcome. + +### Testing + +The test suite is built to run against live S3 and GCloud buckets. You can +disable live tests by setting `SKIP_LIVE_TESTS=true` or running `make +test-skip-live`. To run live tests locally you need to provide API credentials +to test against. Add the credentials to a file named `storage-credentials` in +the root of the project directory: + +```bash +export AWS_ACCESS_KEY_ID='...' +export AWS_SECRET_ACCESS_KEY='...' +export GCLOUD_CREDENTIALS='{...}' # Google Cloud credentials as JSON +``` + +Install test dependencies and target Django version: + +```bash +python3 -m pip install -r test-requirements.txt +python3 -m pip install django==2.2 +``` + +Run test suite: + +```bash +make test +``` + +Code quality tools are broken out from test requirements because some of them +only install on Python >= 3.7. + +```bash +python3 -m pip install -r lint-requirements.txt +``` + +Run linters and static type check: + +```bash +make lint +``` + + +## License + +Collectfast is licensed under the MIT License, see LICENSE file for more +information. diff --git a/README.rst b/README.rst deleted file mode 100644 index df428c0..0000000 --- a/README.rst +++ /dev/null @@ -1,198 +0,0 @@ -Collectfast -=========== - -A faster collectstatic command. - -|Build Status| |Coverage Status| - -**Features** - -- Compares and caches file checksums before uploading -- Parallelizes file uploads using Python's multiprocessing module - -**Supported Storage Backends** - -- ``storages.backends.s3boto.S3BotoStorage`` (deprecated, will be removed in 2.0) -- ``storages.backends.s3boto3.S3Boto3Storage`` -- ``storages.backends.gcloud.GoogleCloudStorage`` -- ``django.core.files.storage.FileSystemStorage`` - -Running Django's ``collectstatic`` command can become painfully slow as more -and more files are added to a project, especially when heavy libraries such as -jQuery UI are included in source code. Collectfast customizes the builtin -``collectstatic`` command, adding different optimizations to make uploading -large amounts of files much faster. - - -Installation ------------- - -Install the app using pip: - -:: - - $ pip install Collectfast - -Make sure you have this in your settings file and add ``'collectfast'`` to your -``INSTALLED_APPS``, before ``'django.contrib.staticfiles'``: - -.. code:: python - - STATICFILES_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" - COLLECTFAST_STRATEGY = "collectfast.strategies.boto3.Boto3Strategy" - INSTALLED_APPS = ( - # ... - 'collectfast', - ) - -**Note:** ``'collectfast'`` must come before ``'django.contrib.staticfiles'`` in -``INSTALLED_APPS``. - -**Note:** Boto strategies will set ``preload_metadata`` on the remote storage to -``True``, see `#30 `_. - - -Supported Strategies -~~~~~~~~~~~~~~~~~~~~ - -+--------------------------------------------------------+-----------------------------------------------+ -|Strategy class |Storage class | -+========================================================+===============================================+ -|``collectfast.strategies.boto.BotoStrategy`` |``storages.backends.s3boto.S3BotoStorage`` | -+--------------------------------------------------------+-----------------------------------------------+ -|``collectfast.strategies.boto3.Boto3Strategy`` |``storages.backends.s3boto3.S3Boto3Storage`` | -+--------------------------------------------------------+-----------------------------------------------+ -|``collectfast.strategies.gcloud.GoogleCloudStrategy`` |``storages.backends.gcloud.GoogleCloudStorage``| -+--------------------------------------------------------+-----------------------------------------------+ -|``collectfast.strategies.filesystem.FileSystemStrategy``|``django.core.files.storage.FileSystemStorage``| -+--------------------------------------------------------+-----------------------------------------------+ - - -Usage ------ - -Collectfast overrides Django's builtin ``collectstatic`` command so just run -``python manage.py collectstatic`` as normal. You can disable Collectfast by -using the ``--disable-collectfast`` option. - -You can also disable Collectfast by setting ``COLLECTFAST_ENABLED = False`` in -your settings file. This is useful when using a local file storage backend for -development. - - -Setup Dedicated Cache Backend ------------------------------ - -It's recommended to setup a dedicated cache backend for Collectfast. Every -time Collectfast does not find a lookup for a file in the cache it will trigger -a lookup to the storage backend, so it's recommended to have a fairly high -``TIMEOUT`` setting. - -Set up your dedicated cache in settings.py with the ``COLLECTFAST_CACHE`` -setting: - -.. code:: python - - CACHES = { - 'default': { - # Your default cache - }, - 'collectfast': { - # Your dedicated Collectfast cache - } - } - - COLLECTFAST_CACHE = 'collectfast' - -By default Collectfast will use the ``default`` cache. - -**Note:** Collectfast will never clean the cache of obsolete files. To clean -out the entire cache, use ``cache.clear()``. `Read more about Django's cache -framework. `_ - -**Note:** We recommend you to set the ``MAX_ENTRIES`` setting if you have more -than 300 static files, see `#47 -`_ - - -Enable Parallelization ----------------------- - -The parallelization feature enables parallel file uploads using Python's -multiprocessing module. Enable it by setting the ``COLLECTFAST_THREADS`` -setting. - -To enable parallelization of file copying, a dedicated cache backend must be -setup and it must use a backend that is threadsafe, i.e. something other than -Django's default LocMemCache. - -.. code:: python - - COLLECTFAST_THREADS = 20 - - -Debug ------ - -By default, Collectfast will suppress any exceptions that happens when copying -and let Django's ``collectstatic`` handle it. To debug those suppressed errors -you can set ``COLLECTFAST_DEBUG = True`` in your Django settings file. - - -Contribution ------------- - -Please feel free to contribute by using issues and pull requests. Discussion is -open and welcome. - -**Testing** - -The test suite is built to run against live S3 and GCloud buckets. You can disable live -tests by setting ``SKIP_LIVE_TESTS=true`` or running ``make test-skip-live``. To run -live tests locally you need to provide API credentials to test against. Add the -credentials to a file named `storage-credentials` in the root of the project directory: - -.. code:: bash - - export AWS_ACCESS_KEY_ID='...' - export AWS_SECRET_ACCESS_KEY='...' - export GCLOUD_CREDENTIALS='{...}' # Google Cloud credentials as JSON - -Install test dependencies and target Django version: - -.. code:: bash - - pip install -r test-requirements.txt - pip install django==2.2 - -Run test suite: - -.. code:: bash - - make test - -Code quality tools are broken out from test requirements because some of them -only install on Python >= 3.7. - -.. code:: bash - - pip install -r lint-requirements.txt - -Run linters and static type check: - -.. code:: bash - - make lint - - -License -------- - -Collectfast is licensed under the MIT License, see LICENSE file for more -information. - - -.. |Build Status| image:: https://api.travis-ci.org/antonagestam/collectfast.svg?branch=master - :target: https://travis-ci.org/antonagestam/collectfast -.. |Coverage Status| image:: https://coveralls.io/repos/github/antonagestam/collectfast/badge.svg?branch=master - :target: https://coveralls.io/github/antonagestam/collectfast?branch=master diff --git a/collectfast/__init__.py b/collectfast/__init__.py index f708a9b..8c0d5d5 100644 --- a/collectfast/__init__.py +++ b/collectfast/__init__.py @@ -1 +1 @@ -__version__ = "1.3.2" +__version__ = "2.0.0" diff --git a/collectfast/management/commands/collectstatic.py b/collectfast/management/commands/collectstatic.py index d7a4433..537a469 100644 --- a/collectfast/management/commands/collectstatic.py +++ b/collectfast/management/commands/collectstatic.py @@ -15,7 +15,6 @@ from collectfast import __version__ from collectfast import settings from collectfast.strategies import DisabledStrategy -from collectfast.strategies import guess_strategy from collectfast.strategies import load_strategy from collectfast.strategies import Strategy @@ -23,35 +22,27 @@ class Command(collectstatic.Command): - def __init__(self, *args, **kwargs): - # type: (Any, Any) -> None + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.num_copied_files = 0 - self.tasks = [] # type: List[Task] + self.tasks: List[Task] = [] self.collectfast_enabled = settings.enabled - self.strategy = DisabledStrategy(Storage()) # type: Strategy + self.strategy: Strategy = DisabledStrategy(Storage()) @staticmethod - def _load_strategy(): - # type: () -> Type[Strategy[Storage]] + def _load_strategy() -> Type[Strategy[Storage]]: strategy_str = getattr(django_settings, "COLLECTFAST_STRATEGY", None) if strategy_str is not None: return load_strategy(strategy_str) - storage_str = getattr(django_settings, "STATICFILES_STORAGE", None) - if storage_str is not None: - return load_strategy(guess_strategy(storage_str)) - raise ImproperlyConfigured( "No strategy configured, please make sure COLLECTFAST_STRATEGY is set." ) - def get_version(self): - # type: () -> str + def get_version(self) -> str: return __version__ - def add_arguments(self, parser): - # type: (CommandParser) -> None + def add_arguments(self, parser: CommandParser) -> None: super().add_arguments(parser) parser.add_argument( "--disable-collectfast", @@ -61,8 +52,7 @@ def add_arguments(self, parser): help="Disable Collectfast.", ) - def set_options(self, **options): - # type: (Any) -> None + def set_options(self, **options: Any) -> None: """Set options and handle deprecation.""" self.collectfast_enabled = self.collectfast_enabled and not options.pop( "disable_collectfast" @@ -71,8 +61,7 @@ def set_options(self, **options): self.strategy = self._load_strategy()(self.storage) super().set_options(**options) - def collect(self): - # type: () -> Dict[str, List[str]] + def collect(self) -> Dict[str, List[str]]: """ Override collect to copy files concurrently. The tasks are populated by Command.copy_file() which is called by super().collect(). @@ -84,8 +73,7 @@ def collect(self): Pool(settings.threads).map(self.maybe_copy_file, self.tasks) return ret - def handle(self, *args, **options): - # type: (Any, Any) -> Optional[str] + def handle(self, *args: Any, **options: Any) -> Optional[str]: """Override handle to suppress summary output.""" ret = super().handle(**options) if not self.collectfast_enabled: @@ -94,8 +82,7 @@ def handle(self, *args, **options): self.num_copied_files, "" if self.num_copied_files == 1 else "s" ) - def maybe_copy_file(self, args): - # type: (Tuple[str, str, Storage]) -> None + def maybe_copy_file(self, args: Task) -> None: """Determine if file should be copied or not and handle exceptions.""" path, prefixed_path, source_storage = args @@ -109,8 +96,7 @@ def maybe_copy_file(self, args): self.num_copied_files += 1 return super().copy_file(path, prefixed_path, source_storage) - def copy_file(self, path, prefixed_path, source_storage): - # type: (str, str, Storage) -> None + def copy_file(self, path: str, prefixed_path: str, source_storage: Storage) -> None: """ Append path to task queue if threads are enabled, otherwise copy the file with a blocking call. @@ -121,8 +107,9 @@ def copy_file(self, path, prefixed_path, source_storage): else: self.maybe_copy_file(args) - def delete_file(self, path, prefixed_path, source_storage): - # type: (str, str, Storage) -> bool + def delete_file( + self, path: str, prefixed_path: str, source_storage: Storage + ) -> bool: """Override delete_file to skip modified time and exists lookups.""" if not self.collectfast_enabled: return super().delete_file(path, prefixed_path, source_storage) diff --git a/collectfast/py.typed b/collectfast/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/collectfast/settings.py b/collectfast/settings.py index 1937635..8c6f267 100644 --- a/collectfast/settings.py +++ b/collectfast/settings.py @@ -4,17 +4,17 @@ from typing_extensions import Final -debug = getattr( +debug: Final[bool] = getattr( settings, "COLLECTFAST_DEBUG", getattr(settings, "DEBUG", False) -) # type: Final[bool] -cache_key_prefix = getattr( +) +cache_key_prefix: Final[str] = getattr( settings, "COLLECTFAST_CACHE_KEY_PREFIX", "collectfast06_asset_" -) # type: Final[str] -cache = getattr(settings, "COLLECTFAST_CACHE", "default") # type: Final[str] -threads = getattr(settings, "COLLECTFAST_THREADS", False) # type: Final[bool] -enabled = getattr(settings, "COLLECTFAST_ENABLED", True) # type: Final[bool] -aws_is_gzipped = getattr(settings, "AWS_IS_GZIPPED", False) # type: Final[bool] -gzip_content_types = getattr( +) +cache: Final[str] = getattr(settings, "COLLECTFAST_CACHE", "default") +threads: Final[bool] = getattr(settings, "COLLECTFAST_THREADS", False) +enabled: Final[bool] = getattr(settings, "COLLECTFAST_ENABLED", True) +aws_is_gzipped: Final[bool] = getattr(settings, "AWS_IS_GZIPPED", False) +gzip_content_types: Final[Sequence[str]] = getattr( settings, "GZIP_CONTENT_TYPES", ( @@ -24,4 +24,4 @@ "application/x-javascript", "image/svg+xml", ), -) # type: Final[Sequence[str]] +) diff --git a/collectfast/strategies/__init__.py b/collectfast/strategies/__init__.py index 4d93b5e..2b0b2d6 100644 --- a/collectfast/strategies/__init__.py +++ b/collectfast/strategies/__init__.py @@ -1,6 +1,5 @@ from .base import DisabledStrategy -from .base import guess_strategy from .base import load_strategy from .base import Strategy -__all__ = ("load_strategy", "guess_strategy", "Strategy", "DisabledStrategy") +__all__ = ("load_strategy", "Strategy", "DisabledStrategy") diff --git a/collectfast/strategies/base.py b/collectfast/strategies/base.py index 198e2d4..5e8816d 100644 --- a/collectfast/strategies/base.py +++ b/collectfast/strategies/base.py @@ -4,7 +4,6 @@ import logging import mimetypes import pydoc -import warnings from functools import lru_cache from io import BytesIO from typing import ClassVar @@ -20,7 +19,6 @@ from django.core.exceptions import ImproperlyConfigured from django.core.files.storage import Storage from django.utils.encoding import force_bytes -from typing_extensions import Final from collectfast import settings @@ -35,15 +33,15 @@ class Strategy(abc.ABC, Generic[_RemoteStorage]): # Exceptions raised by storage backend for delete calls to non-existing # objects. The command silently catches these. - delete_not_found_exception = () # type: ClassVar[Tuple[Type[Exception], ...]] + delete_not_found_exception: ClassVar[Tuple[Type[Exception], ...]] = () - def __init__(self, remote_storage): - # type: (_RemoteStorage) -> None + def __init__(self, remote_storage: _RemoteStorage) -> None: self.remote_storage = remote_storage @abc.abstractmethod - def should_copy_file(self, path, prefixed_path, local_storage): - # type: (str, str, Storage) -> bool + def should_copy_file( + self, path: str, prefixed_path: str, local_storage: Storage + ) -> bool: """ Called for each file before copying happens, this method decides whether a file should be copied or not. Return False to indicate that @@ -52,8 +50,7 @@ def should_copy_file(self, path, prefixed_path, local_storage): """ ... - def pre_should_copy_hook(self): - # type: () -> None + def pre_should_copy_hook(self) -> None: """Hook called before calling should_copy_file.""" ... @@ -61,22 +58,23 @@ def pre_should_copy_hook(self): class HashStrategy(Strategy[_RemoteStorage], abc.ABC): use_gzip = False - def should_copy_file(self, path, prefixed_path, local_storage): - # type: (str, str, Storage) -> bool + def should_copy_file( + self, path: str, prefixed_path: str, local_storage: Storage + ) -> bool: local_hash = self.get_local_file_hash(path, local_storage) remote_hash = self.get_remote_file_hash(prefixed_path) return local_hash != remote_hash - def get_gzipped_local_file_hash(self, uncompressed_file_hash, path, contents): - # type: (str, str, str) -> str + def get_gzipped_local_file_hash( + self, uncompressed_file_hash: str, path: str, contents: str + ) -> str: buffer = BytesIO() zf = gzip.GzipFile(mode="wb", compresslevel=6, fileobj=buffer, mtime=0.0) zf.write(force_bytes(contents)) zf.close() return hashlib.md5(buffer.getvalue()).hexdigest() - def get_local_file_hash(self, path, local_storage): - # type: (str, Storage) -> str + def get_local_file_hash(self, path: str, local_storage: Storage) -> str: """Create md5 hash from file contents.""" contents = local_storage.open(path).read() file_hash = hashlib.md5(contents).hexdigest() @@ -89,24 +87,22 @@ def get_local_file_hash(self, path, local_storage): return file_hash @abc.abstractmethod - def get_remote_file_hash(self, prefixed_path): - # type: (str) -> Optional[str] + def get_remote_file_hash(self, prefixed_path: str) -> Optional[str]: ... class CachingHashStrategy(HashStrategy[_RemoteStorage], abc.ABC): @lru_cache() - def get_cache_key(self, path): - # type: (str) -> str + def get_cache_key(self, path: str) -> str: path_hash = hashlib.md5(path.encode()).hexdigest() return settings.cache_key_prefix + path_hash - def invalidate_cached_hash(self, path): - # type: (str) -> None + def invalidate_cached_hash(self, path: str) -> None: cache.delete(self.get_cache_key(path)) - def should_copy_file(self, path, prefixed_path, local_storage): - # type: (str, str, Storage) -> bool + def should_copy_file( + self, path: str, prefixed_path: str, local_storage: Storage + ) -> bool: local_hash = self.get_local_file_hash(path, local_storage) remote_hash = self.get_cached_remote_file_hash(path, prefixed_path) if local_hash != remote_hash: @@ -116,8 +112,7 @@ def should_copy_file(self, path, prefixed_path, local_storage): return True return False - def get_cached_remote_file_hash(self, path, prefixed_path): - # type: (str, str) -> str + def get_cached_remote_file_hash(self, path: str, prefixed_path: str) -> str: """Cache the hash of the remote storage file.""" cache_key = self.get_cache_key(path) hash_ = cache.get(cache_key, False) @@ -126,8 +121,9 @@ def get_cached_remote_file_hash(self, path, prefixed_path): cache.set(cache_key, hash_) return str(hash_) - def get_gzipped_local_file_hash(self, uncompressed_file_hash, path, contents): - # type: (str, str, str) -> str + def get_gzipped_local_file_hash( + self, uncompressed_file_hash: str, path: str, contents: str + ) -> str: """Cache the hash of the gzipped local file.""" cache_key = self.get_cache_key("gzip_hash_%s" % uncompressed_file_hash) file_hash = cache.get(cache_key, False) @@ -140,12 +136,12 @@ def get_gzipped_local_file_hash(self, uncompressed_file_hash, path, contents): class DisabledStrategy(Strategy): - def should_copy_file(self, path, prefixed_path, local_storage): - # type: (str, str, Storage) -> NoReturn + def should_copy_file( + self, path: str, prefixed_path: str, local_storage: Storage + ) -> NoReturn: raise NotImplementedError - def pre_should_copy_hook(self): - # type: () -> NoReturn + def pre_should_copy_hook(self) -> NoReturn: raise NotImplementedError @@ -158,42 +154,3 @@ def load_strategy(klass: Union[str, type, object]) -> Type[Strategy[Storage]]: % (Strategy.__module__, Strategy.__qualname__) ) return klass - - -_BOTO_STORAGE = "storages.backends.s3boto.S3BotoStorage" # type: Final -_BOTO_STRATEGY = "collectfast.strategies.boto.BotoStrategy" # type: Final -_BOTO3_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" # type: Final -_BOTO3_STRATEGY = "collectfast.strategies.boto3.Boto3Strategy" # type: Final - - -def _resolves_to_subclass(subclass_ref: str, superclass_ref: str) -> bool: - try: - subclass = pydoc.locate(subclass_ref) - assert isinstance(subclass, type) - superclass = pydoc.locate(superclass_ref) - assert isinstance(superclass, type) - except (ImportError, AssertionError, pydoc.ErrorDuringImport) as e: - logger.debug("Failed to import %s: %s" % (superclass_ref, e)) - return False - return issubclass(subclass, superclass) - - -def guess_strategy(storage: str) -> str: - warnings.warn( - "Falling back to guessing strategy for backwards compatibility. This " - "is deprecated and will be removed in a future release. Explicitly " - "set COLLECTFAST_STRATEGY to silence this warning.", - DeprecationWarning, - ) - if storage == _BOTO_STORAGE: - return _BOTO_STRATEGY - if storage == _BOTO3_STORAGE: - return _BOTO3_STRATEGY - if _resolves_to_subclass(storage, _BOTO_STORAGE): - return _BOTO_STRATEGY - if _resolves_to_subclass(storage, _BOTO3_STORAGE): - return _BOTO3_STRATEGY - raise ImproperlyConfigured( - "Collectfast failed to guess strategy, please make sure " - "COLLECTFAST_STRATEGY is set." - ) diff --git a/collectfast/strategies/boto.py b/collectfast/strategies/boto.py deleted file mode 100644 index dd817d8..0000000 --- a/collectfast/strategies/boto.py +++ /dev/null @@ -1,54 +0,0 @@ -import logging -import warnings -from typing import Optional - -import boto.exception -from storages.backends.s3boto import S3BotoStorage -from storages.utils import safe_join - -from .base import CachingHashStrategy -from collectfast import settings - -logger = logging.getLogger(__name__) - - -class BotoStrategy(CachingHashStrategy[S3BotoStorage]): - def __init__(self, remote_storage): - # type: (S3BotoStorage) -> None - warnings.warn( - "The BotoStrategy class is deprecated and will be removed in Collectfast " - "2.0.", - DeprecationWarning, - ) - super().__init__(remote_storage) - self.remote_storage.preload_metadata = True - self.use_gzip = settings.aws_is_gzipped - - def _normalize_path(self, prefixed_path): - # type: (str) -> str - full_path = str(safe_join(self.remote_storage.location, prefixed_path)) - return full_path.replace("\\", "/") - - @staticmethod - def _clean_hash(quoted_hash): - # type: (Optional[str]) -> Optional[str] - """boto returns hashes wrapped in quotes that need to be stripped.""" - if quoted_hash is None: - return None - assert quoted_hash[0] == quoted_hash[-1] == '"' - return quoted_hash[1:-1] - - def get_remote_file_hash(self, prefixed_path): - # type: (str) -> Optional[str] - normalized_path = self._normalize_path(prefixed_path) - logger.debug("Getting file hash", extra={"normalized_path": normalized_path}) - try: - hash_ = self.remote_storage.bucket.get_key( - normalized_path - ).etag # type: str - except AttributeError: - return None - except boto.exception.S3ResponseError: - logger.debug("Error on remote hash request", exc_info=True) - return None - return self._clean_hash(hash_) diff --git a/collectfast/strategies/boto3.py b/collectfast/strategies/boto3.py index 8b89455..85cc100 100644 --- a/collectfast/strategies/boto3.py +++ b/collectfast/strategies/boto3.py @@ -12,41 +12,34 @@ class Boto3Strategy(CachingHashStrategy[S3Boto3Storage]): - def __init__(self, remote_storage): - # type: (S3Boto3Storage) -> None + def __init__(self, remote_storage: S3Boto3Storage) -> None: super().__init__(remote_storage) self.remote_storage.preload_metadata = True self.use_gzip = settings.aws_is_gzipped - def _normalize_path(self, prefixed_path): - # type: (str) -> str + def _normalize_path(self, prefixed_path: str) -> str: path = str(safe_join(self.remote_storage.location, prefixed_path)) return path.replace("\\", "") @staticmethod - def _clean_hash(quoted_hash): - # type: (Optional[str]) -> Optional[str] + def _clean_hash(quoted_hash: Optional[str]) -> Optional[str]: """boto returns hashes wrapped in quotes that need to be stripped.""" if quoted_hash is None: return None assert quoted_hash[0] == quoted_hash[-1] == '"' return quoted_hash[1:-1] - def get_remote_file_hash(self, prefixed_path): - # type: (str) -> Optional[str] + def get_remote_file_hash(self, prefixed_path: str) -> Optional[str]: normalized_path = self._normalize_path(prefixed_path) logger.debug("Getting file hash", extra={"normalized_path": normalized_path}) try: - hash_ = self.remote_storage.bucket.Object( - normalized_path - ).e_tag # type: str + hash_: str = self.remote_storage.bucket.Object(normalized_path).e_tag except botocore.exceptions.ClientError: logger.debug("Error on remote hash request", exc_info=True) return None return self._clean_hash(hash_) - def pre_should_copy_hook(self): - # type: () -> None + def pre_should_copy_hook(self) -> None: if settings.threads: logger.info("Resetting connection") self.remote_storage._connection = None diff --git a/collectfast/strategies/filesystem.py b/collectfast/strategies/filesystem.py index 349f1fe..3d54bce 100644 --- a/collectfast/strategies/filesystem.py +++ b/collectfast/strategies/filesystem.py @@ -6,8 +6,7 @@ class FileSystemStrategy(HashStrategy[FileSystemStorage]): - def get_remote_file_hash(self, prefixed_path): - # type: (str) -> Optional[str] + def get_remote_file_hash(self, prefixed_path: str) -> Optional[str]: try: return self.get_local_file_hash(prefixed_path, self.remote_storage) except FileNotFoundError: diff --git a/collectfast/strategies/gcloud.py b/collectfast/strategies/gcloud.py index 9f3f077..88a20a4 100644 --- a/collectfast/strategies/gcloud.py +++ b/collectfast/strategies/gcloud.py @@ -11,8 +11,7 @@ class GoogleCloudStrategy(CachingHashStrategy[GoogleCloudStorage]): delete_not_found_exception = (NotFound,) - def get_remote_file_hash(self, prefixed_path): - # type: (str) -> Optional[str] + def get_remote_file_hash(self, prefixed_path: str) -> Optional[str]: normalized_path = prefixed_path.replace("\\", "/") blob = self.remote_storage.bucket.get_blob(normalized_path) if blob is None: diff --git a/collectfast/tests/command/test_command.py b/collectfast/tests/command/test_command.py index cab0998..10540c7 100644 --- a/collectfast/tests/command/test_command.py +++ b/collectfast/tests/command/test_command.py @@ -19,25 +19,18 @@ STATICFILES_STORAGE="storages.backends.s3boto3.S3Boto3Storage", COLLECTFAST_STRATEGY="collectfast.strategies.boto3.Boto3Strategy", ), - "boto": override_django_settings( - STATICFILES_STORAGE="storages.backends.s3boto.S3BotoStorage", - COLLECTFAST_STRATEGY="collectfast.strategies.boto.BotoStrategy", +} +all_backend_confs = { + **aws_backend_confs, + "gcloud": override_django_settings( + STATICFILES_STORAGE="storages.backends.gcloud.GoogleCloudStorage", + COLLECTFAST_STRATEGY="collectfast.strategies.gcloud.GoogleCloudStrategy", + ), + "filesystem": override_django_settings( + STATICFILES_STORAGE="django.core.files.storage.FileSystemStorage", + COLLECTFAST_STRATEGY="collectfast.strategies.filesystem.FileSystemStrategy", ), } -# use PEP448-style unpacking instead of copy+update once 3.4 support is dropped -all_backend_confs = aws_backend_confs.copy() -all_backend_confs.update( - { - "gcloud": override_django_settings( - STATICFILES_STORAGE="storages.backends.gcloud.GoogleCloudStorage", - COLLECTFAST_STRATEGY="collectfast.strategies.gcloud.GoogleCloudStrategy", - ), - "filesystem": override_django_settings( - STATICFILES_STORAGE="django.core.files.storage.FileSystemStorage", - COLLECTFAST_STRATEGY="collectfast.strategies.filesystem.FileSystemStrategy", - ), - } -) make_test_aws_backends = test_many(**aws_backend_confs) make_test_all_backends = test_many(**all_backend_confs) @@ -45,8 +38,7 @@ @make_test_all_backends @live_test -def test_basics(case): - # type: (TestCase) -> None +def test_basics(case: TestCase) -> None: clean_static_dir() create_static_file() case.assertIn("1 static file copied.", call_collectstatic()) @@ -57,8 +49,7 @@ def test_basics(case): @make_test_all_backends @live_test @override_setting("threads", 5) -def test_threads(case): - # type: (TestCase) -> None +def test_threads(case: TestCase) -> None: clean_static_dir() create_static_file() case.assertIn("1 static file copied.", call_collectstatic()) @@ -67,8 +58,7 @@ def test_threads(case): @make_test -def test_dry_run(case): - # type: (TestCase) -> None +def test_dry_run(case: TestCase) -> None: clean_static_dir() create_static_file() result = call_collectstatic(dry_run=True) @@ -84,8 +74,7 @@ def test_dry_run(case): @live_test @override_storage_attr("gzip", True) @override_setting("aws_is_gzipped", True) -def test_aws_is_gzipped(case): - # type: (TestCase) -> None +def test_aws_is_gzipped(case: TestCase) -> None: clean_static_dir() create_static_file() case.assertIn("1 static file copied.", call_collectstatic()) @@ -93,29 +82,8 @@ def test_aws_is_gzipped(case): case.assertIn("0 static files copied.", call_collectstatic()) -@make_test -@override_django_settings( - STATICFILES_STORAGE="storages.backends.s3boto.S3BotoStorage", - COLLECTFAST_STRATEGY=None, -) -def test_recognizes_boto_storage(case): - # type: (TestCase) -> None - case.assertEqual(Command._load_strategy().__name__, "BotoStrategy") - - -@make_test -@override_django_settings( - STATICFILES_STORAGE="storages.backends.s3boto3.S3Boto3Storage", - COLLECTFAST_STRATEGY=None, -) -def test_recognizes_boto3_storage(case): - # type: (TestCase) -> None - case.assertEqual(Command._load_strategy().__name__, "Boto3Strategy") - - @make_test @override_django_settings(STATICFILES_STORAGE=None, COLLECTFAST_STRATEGY=None) -def test_raises_for_unrecognized_storage(case): - # type: (TestCase) -> None +def test_raises_for_no_configured_strategy(case: TestCase) -> None: with case.assertRaises(ImproperlyConfigured): Command._load_strategy() diff --git a/collectfast/tests/command/test_disable.py b/collectfast/tests/command/test_disable.py index a197282..76ce96f 100644 --- a/collectfast/tests/command/test_disable.py +++ b/collectfast/tests/command/test_disable.py @@ -15,8 +15,7 @@ @override_django_settings( STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage" ) -def test_disable_collectfast_with_default_storage(case): - # type: (TestCase) -> None +def test_disable_collectfast_with_default_storage(case: TestCase) -> None: clean_static_dir() create_static_file() case.assertIn("1 static file copied", call_collectstatic(disable_collectfast=True)) @@ -24,8 +23,7 @@ def test_disable_collectfast_with_default_storage(case): @make_test @live_test -def test_disable_collectfast(case): - # type: (TestCase) -> None +def test_disable_collectfast(case: TestCase) -> None: clean_static_dir() create_static_file() case.assertIn("1 static file copied.", call_collectstatic(disable_collectfast=True)) @@ -33,16 +31,14 @@ def test_disable_collectfast(case): @override_setting("enabled", False) @mock.patch("collectfast.management.commands.collectstatic.Command._load_strategy") -def test_no_load_with_disable_setting(mocked_load_strategy): - # type: (mock.MagicMock) -> None +def test_no_load_with_disable_setting(mocked_load_strategy: mock.MagicMock) -> None: clean_static_dir() call_collectstatic() mocked_load_strategy.assert_not_called() @mock.patch("collectfast.management.commands.collectstatic.Command._load_strategy") -def test_no_load_with_disable_flag(mocked_load_strategy): - # type: (mock.MagicMock) -> None +def test_no_load_with_disable_flag(mocked_load_strategy: mock.MagicMock) -> None: clean_static_dir() call_collectstatic(disable_collectfast=True) mocked_load_strategy.assert_not_called() diff --git a/collectfast/tests/command/utils.py b/collectfast/tests/command/utils.py index 64061e7..9fe9bff 100644 --- a/collectfast/tests/command/utils.py +++ b/collectfast/tests/command/utils.py @@ -4,8 +4,7 @@ from django.core.management import call_command -def call_collectstatic(*args, **kwargs): - # type: (Any, Any) -> str +def call_collectstatic(*args: Any, **kwargs: Any) -> str: out = StringIO() call_command( "collectstatic", *args, verbosity=3, interactive=False, stdout=out, **kwargs diff --git a/collectfast/tests/strategies/test_caching_hash_strategy.py b/collectfast/tests/strategies/test_caching_hash_strategy.py index 9fdc691..a97701d 100644 --- a/collectfast/tests/strategies/test_caching_hash_strategy.py +++ b/collectfast/tests/strategies/test_caching_hash_strategy.py @@ -13,18 +13,15 @@ class Strategy(CachingHashStrategy[FileSystemStorage]): - def __init__(self): - # type: () -> None + def __init__(self) -> None: super().__init__(FileSystemStorage()) - def get_remote_file_hash(self, prefixed_path): - # type: (str) -> None + def get_remote_file_hash(self, prefixed_path: str) -> None: pass @make_test -def test_get_cache_key(case): - # type: (TestCase) -> None +def test_get_cache_key(case: TestCase) -> None: strategy = Strategy() cache_key = strategy.get_cache_key("/some/random/path") prefix_len = len(settings.cache_key_prefix) @@ -36,8 +33,7 @@ def test_get_cache_key(case): @make_test -def test_gets_and_invalidates_hash(case): - # type: (TestCase) -> None +def test_gets_and_invalidates_hash(case: TestCase) -> None: strategy = Strategy() expected_hash = "hash" mocked = mock.MagicMock(return_value=expected_hash) diff --git a/collectfast/tests/strategies/test_guess_strategy.py b/collectfast/tests/strategies/test_guess_strategy.py deleted file mode 100644 index 3a69156..0000000 --- a/collectfast/tests/strategies/test_guess_strategy.py +++ /dev/null @@ -1,54 +0,0 @@ -from unittest import TestCase - -from django.core.exceptions import ImproperlyConfigured - -from collectfast.strategies.base import _BOTO3_STORAGE -from collectfast.strategies.base import _BOTO3_STRATEGY -from collectfast.strategies.base import _BOTO_STORAGE -from collectfast.strategies.base import _BOTO_STRATEGY -from collectfast.strategies.base import guess_strategy -from collectfast.tests.utils import make_test - - -@make_test -def test_guesses_boto_from_exact(case): - # type: (TestCase) -> None - case.assertEqual(guess_strategy(_BOTO_STORAGE), _BOTO_STRATEGY) - - -@make_test -def test_guesses_boto3_from_exact(case): - # type: (TestCase) -> None - case.assertEqual(guess_strategy(_BOTO3_STORAGE), _BOTO3_STRATEGY) - - -@make_test -def test_guesses_boto_from_subclass(case): - # type: (TestCase) -> None - case.assertEqual( - guess_strategy("collectfast.tests.test_storages.boto_subclass.CustomStorage"), - _BOTO_STRATEGY, - ) - - -@make_test -def test_guesses_boto3_from_subclass(case): - # type: (TestCase) -> None - case.assertEqual( - guess_strategy("collectfast.tests.test_storages.boto3_subclass.CustomStorage"), - _BOTO3_STRATEGY, - ) - - -@make_test -def test_raises_improperly_configured_for_unguessable_class(case): - # type: (TestCase) -> None - with case.assertRaises(ImproperlyConfigured): - guess_strategy("collectfast.tests.test_storages.unguessable.UnguessableStorage") - - -@make_test -def test_raises_improperly_configured_for_invalid_type(case): - # type: (TestCase) -> None - with case.assertRaises(ImproperlyConfigured): - guess_strategy("collectfast.tests.test_storages.unguessable.NotAType") diff --git a/collectfast/tests/strategies/test_hash_strategy.py b/collectfast/tests/strategies/test_hash_strategy.py index d49c413..008fd0c 100644 --- a/collectfast/tests/strategies/test_hash_strategy.py +++ b/collectfast/tests/strategies/test_hash_strategy.py @@ -11,18 +11,15 @@ class Strategy(HashStrategy[FileSystemStorage]): - def __init__(self): - # type: () -> None + def __init__(self) -> None: super().__init__(FileSystemStorage()) - def get_remote_file_hash(self, prefixed_path): - # type: (str) -> None + def get_remote_file_hash(self, prefixed_path: str) -> None: pass @make_test -def test_get_file_hash(case): - # type: (TestCase) -> None +def test_get_file_hash(case: TestCase) -> None: strategy = Strategy() local_storage = StaticFilesStorage() @@ -33,8 +30,7 @@ def test_get_file_hash(case): @make_test -def test_should_copy_file(case): - # type: (TestCase) -> None +def test_should_copy_file(case: TestCase) -> None: strategy = Strategy() local_storage = StaticFilesStorage() remote_hash = "foo" diff --git a/collectfast/tests/test_storages/filesystem_subtype.py b/collectfast/tests/test_storages/filesystem_subtype.py index 1292c89..806aa21 100644 --- a/collectfast/tests/test_storages/filesystem_subtype.py +++ b/collectfast/tests/test_storages/filesystem_subtype.py @@ -4,7 +4,6 @@ class TestFileSystemStorage(FileSystemStorage): - def __init__(self): - # type: () -> None + def __init__(self) -> None: output = str(pathlib.Path(__file__).parent / "fs-remote") super().__init__(location=output) diff --git a/collectfast/tests/utils.py b/collectfast/tests/utils.py index 499fe0f..0498247 100644 --- a/collectfast/tests/utils.py +++ b/collectfast/tests/utils.py @@ -21,13 +21,12 @@ os.environ.get("SKIP_LIVE_TESTS") == "true", reason="not running live tests" ) -static_dir = pathlib.Path(django_settings.STATICFILES_DIRS[0]) # type: Final +static_dir: Final = pathlib.Path(django_settings.STATICFILES_DIRS[0]) F = TypeVar("F", bound=Callable[..., Any]) -def make_test(func): - # type: (F) -> Type[unittest.TestCase] +def make_test(func: F) -> Type[unittest.TestCase]: """ Creates a class that inherits from `unittest.TestCase` with the decorated function as a method. Create tests like this: @@ -42,10 +41,8 @@ def make_test(func): return case -def test_many(**mutations): - # type: (Callable[[F], F]) -> Callable[[F], Type[unittest.TestCase]] - def test(func): - # type: (F) -> Type[unittest.TestCase] +def test_many(**mutations: Callable[[F], F]) -> Callable[[F], Type[unittest.TestCase]]: + def test(func: F) -> Type[unittest.TestCase]: """ Creates a class that inherits from `unittest.TestCase` with the decorated function as a method. Create tests like this: @@ -67,8 +64,7 @@ def test(func): return test -def create_static_file(): - # type: () -> None +def create_static_file() -> None: """Write random characters to a file in the static directory.""" filename = "%s.txt" % uuid.uuid4().hex with (static_dir / filename).open("w+") as file_: @@ -76,18 +72,15 @@ def create_static_file(): file_.write(chr(int(random.random() * 64))) -def clean_static_dir(): - # type: () -> None +def clean_static_dir() -> None: for filename in os.listdir(static_dir.as_posix()): file = static_dir / filename if file.is_file(): file.unlink() -def override_setting(name, value): - # type: (str, Any) -> Callable[[F], F] - def decorator(fn): - # type: (F) -> F +def override_setting(name: str, value: Any) -> Callable[[F], F]: + def decorator(fn: F) -> F: @functools.wraps(fn) def wrapper(*args, **kwargs): original = getattr(settings, name) @@ -102,10 +95,8 @@ def wrapper(*args, **kwargs): return decorator -def override_storage_attr(name, value): - # type: (str, Any) -> Callable[[F], F] - def decorator(fn): - # type: (F) -> F +def override_storage_attr(name: str, value: Any) -> Callable[[F], F]: + def decorator(fn: F) -> F: @functools.wraps(fn) def wrapper(*args, **kwargs): storage = import_string(django_settings.STATICFILES_STORAGE) diff --git a/setup.cfg b/setup.cfg index b1475ff..c48a27b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,40 @@ +[metadata] +name = Collectfast +version = attr: collectfast.__version__ +description = A Faster Collectstatic +long_description = file: README.md +long_description_content_type = text/markdown; charset=UTF-8 +license = MIT License +license_file = LICENSE +classifiers = + Environment :: Web Environment + Intended Audience :: Developers + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Framework :: Django +author = Anton Agestam +author_email = git@antonagestam.se +url = https://github.com/antonagestam/collectfast/ + +[options] +include_package_data = True +packages = find: +install_requires = + Django>=2.2 + django-storages>=1.6 + typing-extensions +python_requires = >=3.6 + +[options.package_data] +collectfast = py.typed + +[bdist_wheel] +universal = true + [tool:pytest] DJANGO_SETTINGS_MODULE = collectfast.tests.settings @@ -12,7 +49,7 @@ max-line-length = 88 extend-ignore = E722 F821 [mypy] -python_version = 3.4 +python_version = 3.6 show_error_codes = True pretty = True files = . @@ -23,7 +60,7 @@ strict_equality = True strict_optional = True check_untyped_defs = True disallow_incomplete_defs = True -ignore_missing_imports = True +ignore_missing_imports = False warn_unused_configs = True warn_redundant_casts = True @@ -37,6 +74,9 @@ plugins = [mypy.plugins.django-stubs] django_settings_module = collectfast.tests.settings +[mypy-storages.*,google.*,botocore.*,setuptools.*,pytest.*] +ignore_missing_imports = True + [coverage:run] source = collectfast @@ -46,6 +86,3 @@ exclude_lines = pragma: no cover # ignore non-implementations \.\.\. - -[metadata] -license_file = LICENSE diff --git a/setup.py b/setup.py index 652049c..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,37 +1,3 @@ -#!/usr/bin/env python -from distutils.core import setup +from setuptools import setup -from setuptools import find_packages - -import collectfast - -setup( - name="Collectfast", - description="A Faster Collectstatic", - version=collectfast.__version__, - long_description=open("README.rst").read(), - author="Anton Agestam", - author_email="msn@antonagestam.se", - packages=find_packages(), - url="https://github.com/antonagestam/collectfast/", - license="MIT License", - include_package_data=True, - install_requires=[ - "Django>=1.11", - "django-storages>=1.6,<1.9", - 'typing;python_version<"3.5"', - "typing-extensions", - ], - classifiers=[ - "Environment :: Web Environment", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Framework :: Django", - ], -) +setup() diff --git a/test-requirements.txt b/test-requirements.txt index 2c20ccd..0a194dc 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,10 +1,8 @@ -typing;python_version<"3.5" typing-extensions mock coveralls -django-storages<1.9 +django-storages boto3 -boto google-cloud-storage pytest pytest-django