-
Notifications
You must be signed in to change notification settings - Fork 13
/
Copy pathvalidators.py
234 lines (192 loc) · 8.28 KB
/
validators.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
from abc import abstractmethod
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
from sqlalchemy_file.exceptions import (
AspectRatioValidationError,
ContentTypeValidationError,
DimensionValidationError,
InvalidImageError,
SizeValidationError,
)
from sqlalchemy_file.helpers import convert_size
if TYPE_CHECKING:
from sqlalchemy_file.file import File
class Validator:
"""Interface that must be implemented by file validators.
File validators get executed before a file is stored on the database
using one of the supported fields. Can be used to add additional data
to file object or change it.
"""
@abstractmethod
def process(self, file: "File", attr_key: str) -> None: # pragma: no cover
"""Should be overridden in inherited class.
Parameters:
file: [File][sqlalchemy_file.file.File] object
attr_key: current SQLAlchemy column key. Can be passed to
[ValidationError][sqlalchemy_file.exceptions.ValidationError]
"""
class SizeValidator(Validator):
"""Validate file maximum size.
Attributes:
max_size:
If set, the size of the underlying file must
be below this file size in order to be valid.
The size of the file can be given in one of
the following formats:
| **Suffix** | **Unit Name** | **Value** | **Example** |
|------------|---------------|-----------------|-------------|
| (none) | byte | 1 byte | `4096` |
| k | kilobyte | 1,000 bytes | `200k` |
| M | megabyte | 1,000,000 bytes | `2M` |
| Ki | kibibyte | 1,024 bytes | `32Ki` |
| Mi | mebibyte | 1,048,576 bytes | `8Mi` |
For more information, view
[Wikipedia: Binary prefix](https://en.wikipedia.org/wiki/Binary_prefix)
Example:
```Python
class Attachment(Base):
__tablename__ = "attachment"
id = Column(Integer, autoincrement=True, primary_key=True)
name = Column(String(50), unique=True)
content = Column(FileField(validators=[SizeValidator(max_size="2M")]))
```
Raises:
SizeValidationError: When file `size` is greater than max_size
"""
def __init__(self, max_size: Union[int, str] = 0) -> None:
super().__init__()
self.max_size = max_size
def process(self, file: "File", attr_key: str) -> None:
if file.size > convert_size(self.max_size):
raise SizeValidationError(
attr_key,
f"The file is too large ({file.size} bytes). Allowed maximum size is {self.max_size}.",
)
class ContentTypeValidator(Validator):
"""Validate file mimetype
Attributes:
allowed_content_types: If set, file `content_type`
must be one of the provided list.
Example:
```Python
class Attachment(Base):
__tablename__ = "attachment"
id = Column(Integer, autoincrement=True, primary_key=True)
name = Column(String(50), unique=True)
content = Column(
FileField(validators=[ContentTypeValidator(["text/plain", "text/csv"])])
)
```
Raises:
ContentTypeValidationError: When file `content_type` not in allowed_content_types
"""
def __init__(self, allowed_content_types: Optional[List[str]] = None) -> None:
super().__init__()
self.allowed_content_types = allowed_content_types
def process(self, file: "File", attr_key: str) -> None:
if (
self.allowed_content_types is not None
and file.content_type not in self.allowed_content_types
):
raise ContentTypeValidationError(
attr_key,
f"File content_type {file.content_type} is not allowed. Allowed content_types are: {self.allowed_content_types}",
)
class ImageValidator(ContentTypeValidator):
"""Default Validator for ImageField.
Attributes:
min_wh: Minimum allowed dimension (w, h).
max_wh: Maximum allowed dimension (w, h).
allowed_content_types: An iterable whose items are
allowed content types. Default is `image/*`
min_aspect_ratio: Minimum allowed image aspect ratio.
max_aspect_ratio: Maximum allowed image aspect ratio.
Example:
```Python
class Book(Base):
__tablename__ = "book"
id = Column(Integer, autoincrement=True, primary_key=True)
title = Column(String(100), unique=True)
cover = Column(
ImageField(
image_validator=ImageValidator(
allowed_content_types=["image/x-icon", "image/tiff", "image/jpeg"],
min_wh=(200, 200),
max_wh=(400, 400),
min_aspect_ratio=1,
max_aspect_ratio=16/9,
)
)
)
```
Raises:
ContentTypeValidationError: When file `content_type` not in allowed_content_types
InvalidImageError: When file is not a valid image
DimensionValidationError: When image width and height constraints fail.
Will add `width` and `height` properties to the file object
"""
def __init__(
self,
min_wh: Optional[Tuple[int, int]] = None,
max_wh: Optional[Tuple[int, int]] = None,
min_aspect_ratio: Optional[float] = None,
max_aspect_ratio: Optional[float] = None,
allowed_content_types: Optional[List[str]] = None,
):
try:
from PIL import Image # type: ignore
except ImportError as e:
raise ImportError(
"The 'PIL' module is required for image processing, "
"you can install it using 'pip install Pillow'."
) from e
Image.init()
super().__init__(
allowed_content_types
if allowed_content_types is not None
else list(Image.MIME.values())
)
self.min_width, self.min_height = min_wh if min_wh else (None, None)
self.max_width, self.max_height = max_wh if max_wh else (None, None)
self.min_aspect_ratio = min_aspect_ratio
self.max_aspect_ratio = max_aspect_ratio
self.image = Image
def process(self, file: "File", attr_key: str) -> None:
super().process(file, attr_key)
import PIL
try:
image = self.image.open(file.original_content)
except (PIL.UnidentifiedImageError, OSError):
raise InvalidImageError(attr_key, "Provide valid image file")
width, height = image.width, image.height
if self.min_width and width < self.min_width:
raise DimensionValidationError(
attr_key,
f"Minimum allowed width is: {self.min_width}, but {width} is given.",
)
if self.min_height and height < self.min_height:
raise DimensionValidationError(
attr_key,
f"Minimum allowed height is: {self.min_height}, but {height} is given.",
)
if self.max_width and self.max_width < width:
raise DimensionValidationError(
attr_key,
f"Maximum allowed width is: {self.max_width}, but {width} is given.",
)
if self.max_height and self.max_height < height:
raise DimensionValidationError(
attr_key,
f"Maximum allowed height is: {self.max_height}, but {height} is given.",
)
aspect_ratio = width / height
if (self.min_aspect_ratio and self.min_aspect_ratio > aspect_ratio) or (
self.max_aspect_ratio and self.max_aspect_ratio < aspect_ratio
):
raise AspectRatioValidationError(
attr_key,
f"Invalid aspect ratio {width} / {height} = {aspect_ratio},"
"accepted_range: "
f"{self.min_aspect_ratio} - {self.max_aspect_ratio}",
)
file.update({"width": width, "height": height})
file.original_content.seek(0) # type: ignore[union-attr]