Skip to content

Commit c17732e

Browse files
authored
Merge pull request #7 from python-ellar/type_decorator_test
Added tests for SQLAlchemy type decorators
2 parents 4ec7d90 + 46f1de0 commit c17732e

File tree

11 files changed

+477
-27
lines changed

11 files changed

+477
-27
lines changed

ellar_sqlalchemy/model/typeDecorator/exceptions.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import typing as t
22

33

4-
class ContentTypeValidationError(Exception):
4+
class ContentTypeValidationError(ValueError):
55
def __init__(
66
self,
77
content_type: t.Optional[str] = None,
@@ -18,14 +18,14 @@ def __init__(
1818
super().__init__(message)
1919

2020

21-
class InvalidFileError(Exception):
21+
class InvalidFileError(ValueError):
2222
pass
2323

2424

25-
class InvalidImageOperationError(Exception):
25+
class InvalidImageOperationError(ValueError):
2626
pass
2727

2828

29-
class MaximumAllowedFileLengthError(Exception):
29+
class MaximumAllowedFileLengthError(ValueError):
3030
def __init__(self, max_length: int) -> None:
3131
super().__init__("Cannot store files larger than: %d bytes" % max_length)

ellar_sqlalchemy/model/typeDecorator/file/base.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import time
33
import typing as t
44
import uuid
5+
from abc import abstractmethod
56

67
import sqlalchemy as sa
8+
from ellar.common import UploadFile
79
from ellar.core.files.storages import BaseStorage
810
from ellar.core.files.storages.utils import get_valid_filename
9-
from starlette.datastructures import UploadFile
1011

1112
from ellar_sqlalchemy.model.typeDecorator.exceptions import (
1213
ContentTypeValidationError,
@@ -25,7 +26,10 @@
2526

2627

2728
class FileFieldBase(t.Generic[T]):
28-
FileObject: t.Type[T] = t.cast(t.Type[T], FileObject)
29+
@property
30+
@abstractmethod
31+
def file_object_type(self) -> t.Type[T]:
32+
...
2933

3034
def load_dialect_impl(self, dialect: sa.Dialect) -> t.Any:
3135
if dialect.name == "sqlite":
@@ -74,7 +78,7 @@ def load_from_str(self, data: str) -> T:
7478
def load(self, data: t.Dict[str, t.Any]) -> T:
7579
if "service_name" in data:
7680
data.pop("service_name")
77-
return self.FileObject(storage=self.storage, **data)
81+
return self.file_object_type(storage=self.storage, **data)
7882

7983
def _guess_content_type(self, file: t.IO) -> str: # type:ignore[type-arg]
8084
content = file.read(1024)
@@ -97,14 +101,15 @@ def convert_to_file_object(self, file: UploadFile) -> T:
97101
original_filename = file.filename or unique_name
98102

99103
# use python magic to get the content type
100-
content_type = self._guess_content_type(file.file)
104+
content_type = self._guess_content_type(file.file) or ""
101105
extension = guess_extension(content_type)
102-
assert extension
103-
104106
file_size = get_length(file.file)
105-
saved_filename = (
106-
f"{original_filename[:-len(extension)]}_{unique_name[:-8]}{extension}"
107-
)
107+
if extension:
108+
saved_filename = (
109+
f"{original_filename[:-len(extension)]}_{unique_name[:-8]}{extension}"
110+
)
111+
else:
112+
saved_filename = f"{unique_name[:-8]}_{original_filename}"
108113
saved_filename = get_valid_filename(saved_filename)
109114

110115
init_kwargs = self.get_extra_file_initialization_context(file)
@@ -117,7 +122,7 @@ def convert_to_file_object(self, file: UploadFile) -> T:
117122
file_size=file_size,
118123
saved_filename=saved_filename,
119124
)
120-
return self.FileObject(**init_kwargs)
125+
return self.file_object_type(**init_kwargs)
121126

122127
def process_bind_param_action(
123128
self, value: t.Optional[t.Any], dialect: sa.Dialect
@@ -138,7 +143,7 @@ def process_bind_param_action(
138143
return json.dumps(value.to_dict())
139144
return value.to_dict()
140145

141-
raise InvalidFileError()
146+
raise InvalidFileError(f"{value} is not supported")
142147

143148
def process_result_value_action(
144149
self, value: t.Optional[t.Any], dialect: sa.Dialect

ellar_sqlalchemy/model/typeDecorator/file/field.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88

99
class FileField(FileFieldBase[FileObject], sa.TypeDecorator): # type: ignore[type-arg]
10+
1011
"""
1112
Provide SqlAlchemy TypeDecorator for saving files
1213
## Basic Usage
@@ -28,6 +29,10 @@ def route(file: File[UploadFile]):
2829
2930
"""
3031

32+
@property
33+
def file_object_type(self) -> t.Type[FileObject]:
34+
return FileObject
35+
3136
impl = sa.JSON
3237

3338
def process_bind_param(

ellar_sqlalchemy/model/typeDecorator/file/file_info.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,5 @@ def to_dict(self) -> t.Dict[str, t.Any]:
4242
"service_name": self._storage.service_name(),
4343
}
4444

45-
def __str__(self) -> str:
46-
return f"filename={self.filename}, content_type={self.content_type}, file_size={self.file_size}"
47-
4845
def __repr__(self) -> str:
49-
return str(self)
46+
return f"<{self.__class__.__name__} filename={self.filename}, content_type={self.content_type}, file_size={self.file_size}>"

ellar_sqlalchemy/model/typeDecorator/image/field.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
from io import SEEK_END, BytesIO
33

44
import sqlalchemy as sa
5+
from ellar.common import UploadFile
56
from ellar.core.files.storages import BaseStorage
67
from PIL import Image
7-
from starlette.datastructures import UploadFile
88

99
from ..exceptions import InvalidImageOperationError
1010
from ..file import FileFieldBase
@@ -18,9 +18,10 @@ class ImageFileField(FileFieldBase[ImageFileObject], sa.TypeDecorator): # type:
1818
## Basic Usage
1919
2020
class MyTable(Base):
21-
image:
22-
ImageFileField.FileObject = sa.Column(ImageFileField(storage=FileSystemStorage('path/to/save/files',
23-
max_size=10*MB), nullable=True)
21+
image: ImageFileField.FileObject = sa.Column(
22+
ImageFileField(storage=FileSystemStorage('path/to/save/files', max_size=10*MB),
23+
nullable=True
24+
)
2425
2526
def route(file: File[UploadFile]):
2627
session = SessionLocal()
@@ -46,7 +47,6 @@ def route(file: File[UploadFile]):
4647
"""
4748

4849
impl = sa.JSON
49-
FileObject = ImageFileObject
5050

5151
def __init__(
5252
self,
@@ -60,6 +60,10 @@ def __init__(
6060
super().__init__(*args, storage=storage, max_size=max_size, **kwargs)
6161
self.crop = crop
6262

63+
@property
64+
def file_object_type(self) -> t.Type[ImageFileObject]:
65+
return ImageFileObject
66+
6367
def process_bind_param(
6468
self, value: t.Optional[t.Any], dialect: sa.Dialect
6569
) -> t.Any:
@@ -73,9 +77,12 @@ def process_result_value(
7377
def get_extra_file_initialization_context(
7478
self, file: UploadFile
7579
) -> t.Dict[str, t.Any]:
76-
with Image.open(file.file) as image:
77-
width, height = image.size
78-
return {"width": width, "height": height}
80+
try:
81+
with Image.open(file.file) as image:
82+
width, height = image.size
83+
return {"width": width, "height": height}
84+
except Exception:
85+
return {"width": None, "height": None}
7986

8087
def crop_image_with_box_sizing(
8188
self, file: UploadFile, crop: t.Optional[CroppingDetails] = None

tests/test_type_decorators/__init__.py

Whitespace-only changes.
1.52 MB
Loading
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import os
2+
import uuid
3+
from io import BytesIO
4+
from unittest.mock import patch
5+
6+
import pytest
7+
import sqlalchemy.exc as sa_exc
8+
from ellar.common.datastructures import ContentFile, UploadFile
9+
from ellar.core.files import storages
10+
from starlette.datastructures import Headers
11+
12+
from ellar_sqlalchemy import model
13+
from ellar_sqlalchemy.model.utils import MB
14+
15+
16+
def serialize_file_data(file):
17+
keys = {
18+
"original_filename",
19+
"content_type",
20+
"extension",
21+
"file_size",
22+
"service_name",
23+
}
24+
return {k: v for k, v in file.to_dict().items() if k in keys}
25+
26+
27+
def test_file_column_type(db_service, ignore_base, tmp_path):
28+
path = str(tmp_path / "files")
29+
fs = storages.FileSystemStorage(path)
30+
31+
class File(model.Model):
32+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
33+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
34+
)
35+
file: model.Mapped[model.FileObject] = model.mapped_column(
36+
"file", model.FileField(storage=fs), nullable=False
37+
)
38+
39+
db_service.create_all()
40+
session = db_service.session_factory()
41+
session.add(File(file=ContentFile(b"Testing file column type", name="text.txt")))
42+
session.commit()
43+
44+
file: File = session.execute(model.select(File)).scalar()
45+
assert "content_type=text/plain" in repr(file.file)
46+
47+
data = serialize_file_data(file.file)
48+
assert data == {
49+
"content_type": "text/plain",
50+
"extension": ".txt",
51+
"file_size": 24,
52+
"original_filename": "text.txt",
53+
"service_name": "local",
54+
}
55+
56+
assert os.listdir(path)[0].split(".")[1] == "txt"
57+
58+
59+
def test_file_column_invalid_file_extension(db_service, ignore_base, tmp_path):
60+
fs = storages.FileSystemStorage(str(tmp_path / "files"))
61+
62+
class File(model.Model):
63+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
64+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
65+
)
66+
file: model.Mapped[model.FileObject] = model.mapped_column(
67+
"file",
68+
model.FileField(storage=fs, allowed_content_types=["application/pdf"]),
69+
nullable=False,
70+
)
71+
72+
with pytest.raises(sa_exc.StatementError) as stmt_exc:
73+
db_service.create_all()
74+
session = db_service.session_factory()
75+
session.add(
76+
File(file=ContentFile(b"Testing file column type", name="text.txt"))
77+
)
78+
session.commit()
79+
assert (
80+
str(stmt_exc.value.orig)
81+
== "Content type is not supported text/plain. Valid options are: application/pdf"
82+
)
83+
84+
85+
@patch(
86+
"ellar_sqlalchemy.model.typeDecorator.file.base.magic_mime_from_buffer",
87+
return_value=None,
88+
)
89+
def test_file_column_invalid_file_extension_case_2(
90+
mock_buffer, db_service, ignore_base, tmp_path
91+
):
92+
fs = storages.FileSystemStorage(str(tmp_path / "files"))
93+
94+
class File(model.Model):
95+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
96+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
97+
)
98+
file: model.Mapped[model.FileObject] = model.mapped_column(
99+
"file",
100+
model.FileField(storage=fs, allowed_content_types=["application/pdf"]),
101+
nullable=False,
102+
)
103+
104+
with pytest.raises(sa_exc.StatementError) as stmt_exc:
105+
db_service.create_all()
106+
session = db_service.session_factory()
107+
session.add(
108+
File(
109+
file=UploadFile(
110+
BytesIO(b"Testing file column type"),
111+
size=24,
112+
filename="test.txt",
113+
headers=Headers({"content-type": ""}),
114+
)
115+
)
116+
)
117+
session.commit()
118+
assert mock_buffer.called
119+
assert (
120+
str(stmt_exc.value.orig)
121+
== "Content type is not supported . Valid options are: application/pdf"
122+
)
123+
124+
125+
@patch("ellar_sqlalchemy.model.typeDecorator.file.base.get_length", return_value=MB * 7)
126+
def test_file_column_invalid_file_size_case_2(
127+
mock_buffer, db_service, ignore_base, tmp_path
128+
):
129+
fs = storages.FileSystemStorage(str(tmp_path / "files"))
130+
131+
class File(model.Model):
132+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
133+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
134+
)
135+
file: model.Mapped[model.FileObject] = model.mapped_column(
136+
"file", model.FileField(storage=fs, max_size=MB * 6), nullable=False
137+
)
138+
139+
with pytest.raises(sa_exc.StatementError) as stmt_exc:
140+
db_service.create_all()
141+
session = db_service.session_factory()
142+
session.add(File(file=ContentFile(b"Testing File Size Validation")))
143+
session.commit()
144+
assert mock_buffer.called
145+
assert str(stmt_exc.value.orig) == "Cannot store files larger than: 6291456 bytes"
146+
147+
148+
def test_file_column_invalid_set(db_service, ignore_base, tmp_path):
149+
fs = storages.FileSystemStorage(str(tmp_path / "files"))
150+
151+
class File(model.Model):
152+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
153+
"id", model.Integer(), nullable=False, unique=True, primary_key=True
154+
)
155+
file: model.Mapped[model.FileObject] = model.mapped_column(
156+
"file", model.FileField(storage=fs, max_size=MB * 6), nullable=False
157+
)
158+
159+
db_service.create_all()
160+
session = db_service.session_factory()
161+
with pytest.raises(sa_exc.StatementError) as stmt_exc:
162+
session.add(File(file={}))
163+
session.commit()
164+
165+
assert str(stmt_exc.value.orig) == "{} is not supported"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import uuid
2+
3+
from ellar_sqlalchemy import model
4+
5+
6+
def test_guid_column_type(db_service, ignore_base):
7+
uid = uuid.uuid4()
8+
9+
class Guid(model.Model):
10+
id: model.Mapped[uuid.uuid4] = model.mapped_column(
11+
"id",
12+
model.GUID(),
13+
nullable=False,
14+
unique=True,
15+
primary_key=True,
16+
default=uuid.uuid4,
17+
)
18+
19+
db_service.create_all()
20+
session = db_service.session_factory()
21+
session.add(Guid(id=uid))
22+
session.commit()
23+
24+
guid = session.execute(model.select(Guid)).scalar()
25+
assert guid.id == uid

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy