Skip to content

Commit f34d8bf

Browse files
author
Tomasz Szymański
committed
storage provider added + full tests coverage
1 parent 3d17e32 commit f34d8bf

8 files changed

Lines changed: 286 additions & 47 deletions

File tree

storages/backends/amazon_s3.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging
22
from datetime import datetime
3-
from os import environ
4-
from typing import AnyStr, Optional
3+
from typing import AnyStr
54

65
import boto3
76
from boto3 import Session
@@ -10,23 +9,30 @@
109
from botocore.response import StreamingBody
1110

1211
from storages.backends.base import Storage
12+
from storages.exceptions import ImproperlyConfiguredError
1313

1414

1515
class AmazonS3Storage(Storage):
1616
_SERVICE_NAME = "s3"
1717

1818
def __init__(
1919
self,
20-
aws_access_key_id: Optional[str],
21-
aws_secret_access_key: Optional[str],
22-
bucket_name: Optional[str],
20+
aws_access_key_id: str,
21+
aws_secret_access_key: str,
22+
bucket_name: str,
2323
):
24-
if aws_access_key_id is None:
25-
raise ValueError("aws_access_key_id can not be None.")
26-
if aws_secret_access_key is None:
27-
raise ValueError("aws_secret_access_key can not be None.")
28-
if bucket_name is None:
29-
raise ValueError("bucket_name can not be None.")
24+
if not aws_access_key_id:
25+
raise ImproperlyConfiguredError(
26+
name="aws_access_key_id", value=aws_access_key_id
27+
)
28+
if not aws_secret_access_key:
29+
raise ImproperlyConfiguredError(
30+
name="aws_secret_access_key", value=aws_secret_access_key
31+
)
32+
if not bucket_name:
33+
raise ImproperlyConfiguredError(
34+
name="bucket_name", value=bucket_name
35+
)
3036
self._session: Session = boto3.Session(
3137
aws_access_key_id=aws_access_key_id,
3238
aws_secret_access_key=aws_secret_access_key,
@@ -75,10 +81,3 @@ def get_access_time(self, name: str) -> datetime:
7581
raise NotImplementedError(
7682
"S3 storage does not provide access time info."
7783
)
78-
79-
80-
amazon_s3_storage = AmazonS3Storage(
81-
aws_access_key_id=environ.get("AWS_ACCESS_KEY_ID"),
82-
aws_secret_access_key=environ.get("AWS_SECRET_ACCESS_KEY"),
83-
bucket_name=environ.get("AWS_S3_BUCKET_NAME"),
84-
)

storages/backends/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ def get_modified_time(self, name: str) -> datetime:
3838
@abstractmethod
3939
def get_access_time(self, name: str) -> datetime:
4040
pass # pragma: no cover
41+
42+
43+
class StorageBuilder(ABC):
44+
@abstractmethod
45+
def build(self) -> Storage:
46+
pass # pragma: no cover

storages/backends/file_system.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
from typing import AnyStr, Union
55

66
from storages.backends.base import Storage
7+
from storages.exceptions import ImproperlyConfiguredError
78

89

910
class FileSystemStorage(Storage):
1011
def __init__(self, path: str):
1112
if not path:
12-
raise ValueError("path can not be empty")
13+
raise ImproperlyConfiguredError(name="path", value=path)
1314
self._base_path = path
1415

1516
def _path(self, name: str) -> str:

storages/exceptions.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from typing import Any
2+
3+
4+
class StoragesError(Exception):
5+
pass
6+
7+
8+
class ImproperlyConfiguredError(StoragesError):
9+
def __init__(self, name: str, value: Any):
10+
super().__init__(
11+
f"The '{name}' setting has improper value of: '{value}'"
12+
)
13+
14+
15+
class MissingEnvironmentVariableError(StoragesError):
16+
def __init__(self, name: str):
17+
super().__init__(f"The environment variable '{name}' is not defined")

storages/provider.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import os
2+
from importlib import import_module
3+
from inspect import Signature
4+
from os import environ
5+
from typing import Any, Dict, Generator, Iterable, Tuple, Type
6+
7+
from storages.backends.base import Storage
8+
from storages.exceptions import MissingEnvironmentVariableError
9+
10+
11+
class StorageConstructorArgumentsExtractor:
12+
_DEFAULT_IGNORED_ARGUMENTS = ("self", "cls")
13+
14+
@staticmethod
15+
def extract(
16+
storage_backend_class: Type[Storage],
17+
ignored_arguments: Tuple[str, ...] = _DEFAULT_IGNORED_ARGUMENTS,
18+
) -> Generator[str, None, None]:
19+
signature = Signature.from_callable(storage_backend_class.__init__)
20+
return (
21+
parameter.name
22+
for _, parameter in signature.parameters.items()
23+
if parameter.kind is parameter.POSITIONAL_OR_KEYWORD
24+
and parameter.name not in ignored_arguments
25+
)
26+
27+
28+
class EnvironmentVariablesCollector:
29+
_DEFAULT_PREFIX = "STORAGES_"
30+
31+
@classmethod
32+
def collect(
33+
cls, names: Iterable[str], prefix: str = _DEFAULT_PREFIX
34+
) -> Dict[str, Any]:
35+
values = {}
36+
for name in names:
37+
environment_variable_name = f"{prefix}{name.upper()}"
38+
value = os.environ.get(environment_variable_name)
39+
if value is None:
40+
raise MissingEnvironmentVariableError(
41+
name=environment_variable_name
42+
)
43+
values[name] = value
44+
return values
45+
46+
47+
class DynamicStorageLoader:
48+
_PATH_DELIMITER = "."
49+
50+
@classmethod
51+
def load_class(cls, path: str) -> Type[Storage]:
52+
module_name, class_name = cls._get_module_and_class_name_from_path(
53+
path=path
54+
)
55+
module = import_module(module_name)
56+
return getattr(module, class_name)
57+
58+
@classmethod
59+
def _get_module_and_class_name_from_path(
60+
cls, path: str
61+
) -> Tuple[str, str]:
62+
parts = path.split(cls._PATH_DELIMITER)
63+
return cls._PATH_DELIMITER.join(parts[:-1]), parts[-1]
64+
65+
66+
class StorageProvider:
67+
@classmethod
68+
def provide(cls, backend_path: str):
69+
backend_class = DynamicStorageLoader.load_class(path=backend_path)
70+
constructor_arguments = StorageConstructorArgumentsExtractor.extract(
71+
storage_backend_class=backend_class
72+
)
73+
constructor_argument_values = EnvironmentVariablesCollector.collect(
74+
names=constructor_arguments
75+
)
76+
return backend_class(**constructor_argument_values) # type: ignore
77+
78+
79+
default_storage = StorageProvider.provide(
80+
backend_path=environ["STORAGES_BACKEND"]
81+
)

tests/test_aws_s3_storage.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
from datetime import datetime, timedelta, tzinfo, timezone
1+
from datetime import datetime, timedelta
2+
from os import environ
23
from unittest import TestCase
34
from uuid import uuid4
45

56
import pytest
67

8+
from storages.backends.amazon_s3 import AmazonS3Storage
79
from storages.backends.base import Storage
8-
from storages.backends.amazon_s3 import amazon_s3_storage, AmazonS3Storage
10+
from storages.exceptions import ImproperlyConfiguredError
911

1012

1113
class aws_temp_file:
@@ -30,19 +32,33 @@ def __exit__(self, exc_type, exc_val, exc_tb):
3032

3133

3234
class TestAWSS3Storage(TestCase):
33-
_DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
34-
3535
@pytest.fixture(autouse=True)
3636
def init_storage(self, tmpdir):
37-
self._storage = amazon_s3_storage
37+
self._storage = AmazonS3Storage(
38+
aws_access_key_id=environ.get("STORAGES_AWS_ACCESS_KEY_ID"),
39+
aws_secret_access_key=environ.get(
40+
"STORAGES_AWS_SECRET_ACCESS_KEY"
41+
),
42+
bucket_name=environ.get("STORAGES_BUCKET_NAME"),
43+
)
3844

3945
def test_improper_initialization(self):
40-
with pytest.raises(Exception):
41-
AmazonS3Storage(aws_access_key_id=None, aws_secret_access_key=None, bucket_name=None)
42-
with pytest.raises(Exception):
43-
AmazonS3Storage(aws_access_key_id="somekey", aws_secret_access_key=None, bucket_name=None)
44-
with pytest.raises(Exception):
45-
AmazonS3Storage(aws_access_key_id="some_key", aws_secret_access_key="some_secret", bucket_name=None)
46+
with pytest.raises(ImproperlyConfiguredError):
47+
AmazonS3Storage(
48+
aws_access_key_id="", aws_secret_access_key="", bucket_name=""
49+
)
50+
with pytest.raises(ImproperlyConfiguredError):
51+
AmazonS3Storage(
52+
aws_access_key_id="somekey",
53+
aws_secret_access_key="",
54+
bucket_name="",
55+
)
56+
with pytest.raises(ImproperlyConfiguredError):
57+
AmazonS3Storage(
58+
aws_access_key_id="some_key",
59+
aws_secret_access_key="some_secret",
60+
bucket_name="",
61+
)
4662

4763
def test_file_not_exists(self):
4864
assert not self._storage.exists("some_non_existent.file")
@@ -57,7 +73,10 @@ def test_file_contains_written_data(self):
5773

5874
def test_file_contains_binary_written_data(self):
5975
with aws_temp_file(storage=self._storage, binary=True) as temp_file:
60-
assert self._storage.read(temp_file, mode="rb") == aws_temp_file.CONTENT_BINARY
76+
assert (
77+
self._storage.read(temp_file, mode="rb")
78+
== aws_temp_file.CONTENT_BINARY
79+
)
6180

6281
def test_file_does_not_exist_upon_writing_and_deletion(self):
6382
with aws_temp_file(storage=self._storage) as temp_file:
@@ -69,14 +88,16 @@ def test_file_size_matches_content_size(self):
6988
assert self._storage.size(temp_file) == len(aws_temp_file.CONTENT)
7089

7190
def test_file_creation_time(self):
72-
with pytest.raises(Exception):
91+
with pytest.raises(NotImplementedError):
7392
self._storage.get_created_time("any_name.ext")
7493

7594
def test_file_access_time(self):
76-
with pytest.raises(Exception):
95+
with pytest.raises(NotImplementedError):
7796
self._storage.get_access_time("any_name.ext")
7897

7998
def test_file_modification_time(self):
8099
with aws_temp_file(storage=self._storage) as temp_file:
81-
modification_time = self._storage.get_modified_time(temp_file).replace(tzinfo=None)
100+
modification_time = self._storage.get_modified_time(
101+
temp_file
102+
).replace(tzinfo=None)
82103
assert modification_time - datetime.now() < timedelta(seconds=10)

tests/test_file_system_storage.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import pytest
1+
from datetime import datetime, timedelta
22
from unittest import TestCase
33

4-
from datetime import datetime
4+
import pytest
55

66
from storages.backends.file_system import FileSystemStorage
7+
from storages.exceptions import ImproperlyConfiguredError
78

89

910
class TestFileSystemStorage(TestCase):
1011
_TEST_FILE_NAME = "test_file.txt"
1112
_TEST_FILE_CONTENT = "Lorem ipsum dolor sit amet..."
1213
_TEST_FILE_CONTENT_BINARY = b"Binary lorem ipsum dolor sit amet..."
13-
_DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
1414

1515
@pytest.fixture(autouse=True)
1616
def init_storage(self, tmpdir):
1717
self._storage = FileSystemStorage(path=tmpdir)
1818

1919
def test_improper_storage_initialization(self):
20-
with pytest.raises(ValueError):
20+
with pytest.raises(ImproperlyConfiguredError):
2121
FileSystemStorage(path="")
2222

2323
def test_file_not_exists(self):
@@ -29,11 +29,16 @@ def test_file_exists_upon_writing(self):
2929

3030
def test_file_contains_written_data(self):
3131
self._write_contents_to_file()
32-
assert self._storage.read(self._TEST_FILE_NAME) == self._TEST_FILE_CONTENT
32+
assert (
33+
self._storage.read(self._TEST_FILE_NAME) == self._TEST_FILE_CONTENT
34+
)
3335

3436
def test_file_contains_binary_written_data(self):
3537
self._write_contents_to_file(binary=True)
36-
assert self._storage.read(self._TEST_FILE_NAME, mode="rb") == self._TEST_FILE_CONTENT_BINARY
38+
assert (
39+
self._storage.read(self._TEST_FILE_NAME, mode="rb")
40+
== self._TEST_FILE_CONTENT_BINARY
41+
)
3742

3843
def test_file_does_not_exist_upon_writing_and_deletion(self):
3944
self._write_contents_to_file()
@@ -42,29 +47,35 @@ def test_file_does_not_exist_upon_writing_and_deletion(self):
4247

4348
def test_file_size_matches_content_size(self):
4449
self._write_contents_to_file()
45-
assert self._storage.size(self._TEST_FILE_NAME) == len(self._TEST_FILE_CONTENT)
50+
assert self._storage.size(self._TEST_FILE_NAME) == len(
51+
self._TEST_FILE_CONTENT
52+
)
4653

4754
def test_file_creation_time(self):
4855
now = datetime.utcnow()
4956
self._write_contents_to_file()
5057
creation_time = self._storage.get_created_time(self._TEST_FILE_NAME)
51-
assert creation_time.strftime(self._DATE_TIME_FORMAT) == now.strftime(self._DATE_TIME_FORMAT)
58+
assert creation_time - now < timedelta(seconds=1)
5259

5360
def test_file_modification_time(self):
5461
now = datetime.utcnow()
5562
self._write_contents_to_file()
56-
modification_time = self._storage.get_modified_time(self._TEST_FILE_NAME)
57-
assert modification_time.strftime(self._DATE_TIME_FORMAT) == now.strftime(self._DATE_TIME_FORMAT)
63+
modification_time = self._storage.get_modified_time(
64+
self._TEST_FILE_NAME
65+
)
66+
assert modification_time - now < timedelta(seconds=1)
5867

5968
def test_file_access_time(self):
6069
now = datetime.utcnow()
6170
self._write_contents_to_file()
6271
access_time = self._storage.get_access_time(self._TEST_FILE_NAME)
63-
assert access_time.strftime(self._DATE_TIME_FORMAT) == now.strftime(self._DATE_TIME_FORMAT)
72+
assert access_time - now < timedelta(seconds=1)
6473

6574
def _write_contents_to_file(self, binary: bool = False):
6675
self._storage.write(
6776
self._TEST_FILE_NAME,
68-
content=self._TEST_FILE_CONTENT_BINARY if binary else self._TEST_FILE_CONTENT,
69-
mode="xb" if binary else "x"
77+
content=self._TEST_FILE_CONTENT_BINARY
78+
if binary
79+
else self._TEST_FILE_CONTENT,
80+
mode="xb" if binary else "x",
7081
)

0 commit comments

Comments
 (0)