sqlalchemy-file 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/.gitignore +1 -0
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/PKG-INFO +13 -13
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/README.md +4 -4
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/pyproject.toml +34 -13
- sqlalchemy_file-0.5.0/sqlalchemy_file/__init__.py +4 -0
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/base.py +3 -2
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/exceptions.py +2 -2
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/file.py +8 -9
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/helpers.py +12 -7
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/mutable_list.py +4 -12
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/processors.py +6 -10
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/storage.py +22 -24
- sqlalchemy_file-0.5.0/sqlalchemy_file/stored_file.py +71 -0
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/types.py +73 -62
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/validators.py +8 -11
- sqlalchemy_file-0.4.0/sqlalchemy_file/__init__.py +0 -5
- sqlalchemy_file-0.4.0/sqlalchemy_file/stored_file.py +0 -38
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/LICENSE +0 -0
- {sqlalchemy_file-0.4.0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: sqlalchemy-file
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: SQLAlchemy-file is a SQLAlchemy extension for attaching files to SQLAlchemy model and uploading them to various storage.
|
|
5
5
|
Project-URL: Homepage, https://jowilf.github.io/sqlalchemy-file
|
|
6
6
|
Project-URL: Documentation, https://jowilf.github.io/sqlalchemy-file
|
|
@@ -30,24 +30,24 @@ Requires-Dist: apache-libcloud<3.8,>=3.6
|
|
|
30
30
|
Requires-Dist: sqlalchemy<2.1,>=1.4
|
|
31
31
|
Provides-Extra: dev
|
|
32
32
|
Requires-Dist: pre-commit<4.0.0,>=2.20.0; extra == 'dev'
|
|
33
|
-
Requires-Dist: uvicorn<0.
|
|
33
|
+
Requires-Dist: uvicorn<0.22.0,>=0.20.0; extra == 'dev'
|
|
34
34
|
Provides-Extra: doc
|
|
35
35
|
Requires-Dist: mkdocs-material<10.0.0,>=9.0.0; extra == 'doc'
|
|
36
|
-
Requires-Dist: mkdocstrings[python]<0.
|
|
36
|
+
Requires-Dist: mkdocstrings[python]<0.23.0,>=0.19.0; extra == 'doc'
|
|
37
37
|
Provides-Extra: test
|
|
38
|
-
Requires-Dist: black==23.
|
|
38
|
+
Requires-Dist: black==23.7.0; extra == 'test'
|
|
39
39
|
Requires-Dist: coverage<7.3.0,>=7.0.0; extra == 'test'
|
|
40
|
-
Requires-Dist: fastapi<0.
|
|
40
|
+
Requires-Dist: fastapi<0.96,>=0.92; extra == 'test'
|
|
41
41
|
Requires-Dist: fasteners==0.18; extra == 'test'
|
|
42
42
|
Requires-Dist: flask-sqlalchemy<3.1,>=3.0; extra == 'test'
|
|
43
43
|
Requires-Dist: flask<2.3,>=2.2; extra == 'test'
|
|
44
|
-
Requires-Dist: mypy==1.
|
|
45
|
-
Requires-Dist: pillow<
|
|
44
|
+
Requires-Dist: mypy==1.4.1; extra == 'test'
|
|
45
|
+
Requires-Dist: pillow<10.1.0,>=9.4.0; extra == 'test'
|
|
46
46
|
Requires-Dist: psycopg2-binary<3.0.0,>=2.9.5; extra == 'test'
|
|
47
47
|
Requires-Dist: pymysql[rsa]<1.1.0,>=1.0.2; extra == 'test'
|
|
48
|
-
Requires-Dist: pytest<7.
|
|
48
|
+
Requires-Dist: pytest<7.5.0,>=7.2.0; extra == 'test'
|
|
49
49
|
Requires-Dist: python-multipart==0.0.6; extra == 'test'
|
|
50
|
-
Requires-Dist: ruff==0.0.
|
|
50
|
+
Requires-Dist: ruff==0.0.278; extra == 'test'
|
|
51
51
|
Description-Content-Type: text/markdown
|
|
52
52
|
|
|
53
53
|
# sqlalchemy-file
|
|
@@ -153,8 +153,8 @@ class Attachment(Base):
|
|
|
153
153
|
|
|
154
154
|
|
|
155
155
|
# Configure Storage
|
|
156
|
-
os.makedirs("/
|
|
157
|
-
container = LocalStorageDriver("
|
|
156
|
+
os.makedirs("./upload_dir/attachment", 0o777, exist_ok=True)
|
|
157
|
+
container = LocalStorageDriver("./upload_dir").get_container("attachment")
|
|
158
158
|
StorageManager.add_storage("default", container)
|
|
159
159
|
|
|
160
160
|
# Save your model
|
|
@@ -163,8 +163,8 @@ engine = create_engine(
|
|
|
163
163
|
)
|
|
164
164
|
Base.metadata.create_all(engine)
|
|
165
165
|
|
|
166
|
-
with Session(engine) as session:
|
|
167
|
-
session.add(Attachment(name="attachment1", content=
|
|
166
|
+
with Session(engine) as session, open("./example.txt", "rb") as local_file:
|
|
167
|
+
session.add(Attachment(name="attachment1", content=local_file))
|
|
168
168
|
session.add(Attachment(name="attachment2", content=b"Hello world"))
|
|
169
169
|
session.add(Attachment(name="attachment3", content="Hello world"))
|
|
170
170
|
file = File(content="Hello World", filename="hello.txt", content_type="text/plain")
|
|
@@ -101,8 +101,8 @@ class Attachment(Base):
|
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
# Configure Storage
|
|
104
|
-
os.makedirs("/
|
|
105
|
-
container = LocalStorageDriver("
|
|
104
|
+
os.makedirs("./upload_dir/attachment", 0o777, exist_ok=True)
|
|
105
|
+
container = LocalStorageDriver("./upload_dir").get_container("attachment")
|
|
106
106
|
StorageManager.add_storage("default", container)
|
|
107
107
|
|
|
108
108
|
# Save your model
|
|
@@ -111,8 +111,8 @@ engine = create_engine(
|
|
|
111
111
|
)
|
|
112
112
|
Base.metadata.create_all(engine)
|
|
113
113
|
|
|
114
|
-
with Session(engine) as session:
|
|
115
|
-
session.add(Attachment(name="attachment1", content=
|
|
114
|
+
with Session(engine) as session, open("./example.txt", "rb") as local_file:
|
|
115
|
+
session.add(Attachment(name="attachment1", content=local_file))
|
|
116
116
|
session.add(Attachment(name="attachment2", content=b"Hello world"))
|
|
117
117
|
session.add(Attachment(name="attachment3", content="Hello world"))
|
|
118
118
|
file = File(content="Hello World", filename="hello.txt", content_type="text/plain")
|
|
@@ -43,27 +43,27 @@ Changelog = "https://jowilf.github.io/sqlalchemy-file/changelog/"
|
|
|
43
43
|
|
|
44
44
|
[project.optional-dependencies]
|
|
45
45
|
test = [
|
|
46
|
-
"pytest >=7.2.0, <7.
|
|
47
|
-
"mypy ==1.
|
|
48
|
-
"ruff ==0.0.
|
|
49
|
-
"black ==23.
|
|
46
|
+
"pytest >=7.2.0, <7.5.0",
|
|
47
|
+
"mypy ==1.4.1",
|
|
48
|
+
"ruff ==0.0.278",
|
|
49
|
+
"black ==23.7.0",
|
|
50
50
|
"coverage >=7.0.0, <7.3.0",
|
|
51
51
|
"fasteners ==0.18",
|
|
52
52
|
"PyMySQL[rsa] >=1.0.2, <1.1.0",
|
|
53
53
|
"psycopg2-binary >=2.9.5, <3.0.0",
|
|
54
|
-
"Pillow >=9.4.0, <
|
|
54
|
+
"Pillow >=9.4.0, <10.1.0",
|
|
55
55
|
"python-multipart ==0.0.6",
|
|
56
|
-
"fastapi >=0.92, <0.
|
|
56
|
+
"fastapi >=0.92, <0.96",
|
|
57
57
|
"Flask >=2.2, <2.3",
|
|
58
58
|
"Flask-SQLAlchemy >=3.0,<3.1"
|
|
59
59
|
]
|
|
60
60
|
doc = [
|
|
61
61
|
"mkdocs-material >=9.0.0, <10.0.0",
|
|
62
|
-
"mkdocstrings[python] >=0.19.0, <0.
|
|
62
|
+
"mkdocstrings[python] >=0.19.0, <0.23.0"
|
|
63
63
|
]
|
|
64
64
|
dev = [
|
|
65
65
|
"pre-commit >=2.20.0, <4.0.0",
|
|
66
|
-
"uvicorn >=0.20.0, <0.
|
|
66
|
+
"uvicorn >=0.20.0, <0.22.0",
|
|
67
67
|
]
|
|
68
68
|
|
|
69
69
|
[tool.hatch.envs.default]
|
|
@@ -73,7 +73,7 @@ features = [
|
|
|
73
73
|
]
|
|
74
74
|
[tool.hatch.envs.default.scripts]
|
|
75
75
|
format = [
|
|
76
|
-
"ruff sqlalchemy_file tests
|
|
76
|
+
"ruff sqlalchemy_file tests --fix",
|
|
77
77
|
"black ."
|
|
78
78
|
]
|
|
79
79
|
|
|
@@ -135,18 +135,39 @@ source = ["sqlalchemy_file", "tests"]
|
|
|
135
135
|
|
|
136
136
|
[tool.ruff]
|
|
137
137
|
select = [
|
|
138
|
+
"B", # flake8-bugbear
|
|
139
|
+
"C4", # flake8-comprehensions
|
|
140
|
+
"C90", # mccabe
|
|
141
|
+
"D", # pydocstyle
|
|
138
142
|
"E", # pycodestyle errors
|
|
139
|
-
"
|
|
143
|
+
"ERA", # eradicate
|
|
140
144
|
"F", # pyflakes
|
|
141
145
|
"I", # isort
|
|
142
|
-
"
|
|
143
|
-
"
|
|
146
|
+
"N", # pep8-naming
|
|
147
|
+
"PIE", # flake8-pie,
|
|
148
|
+
"PLC", # pylint - convention
|
|
149
|
+
"PLE", # pylint - error
|
|
150
|
+
"PLW", # pylint - warning
|
|
151
|
+
"Q", # flake8-quotes
|
|
152
|
+
"RET", # flake8-return,
|
|
153
|
+
"RUF", # Ruff-specific rules
|
|
154
|
+
"SIM", # flake8-simplify
|
|
155
|
+
"UP", # pyupgrade
|
|
156
|
+
"W", # pycodestyle warnings
|
|
144
157
|
]
|
|
145
|
-
ignore = ["E501", "B904", "B008"]
|
|
158
|
+
ignore = ["E501", "B904", "B008", "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D205"]
|
|
159
|
+
target-version = "py38"
|
|
160
|
+
|
|
161
|
+
[tool.ruff.pydocstyle]
|
|
162
|
+
convention = "google"
|
|
146
163
|
|
|
147
164
|
[tool.ruff.isort]
|
|
148
165
|
known-third-party = ["sqlalchemy_file"]
|
|
149
166
|
|
|
167
|
+
[tool.ruff.per-file-ignores]
|
|
168
|
+
"__init__.py" = ["F401"]
|
|
169
|
+
"docs_src/**" = ["N999"]
|
|
170
|
+
|
|
150
171
|
[tool.mypy]
|
|
151
172
|
strict = true
|
|
152
173
|
warn_unused_ignores = false
|
|
@@ -3,8 +3,9 @@ from typing import Any
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class BaseFile(typing.Dict[str, Any]):
|
|
6
|
-
"""
|
|
7
|
-
|
|
6
|
+
"""Base class for file object.
|
|
7
|
+
|
|
8
|
+
It keeps information on a content related to a specific storage.
|
|
8
9
|
It is a specialized dictionary that provides also attribute style access,
|
|
9
10
|
the dictionary parent permits easy encoding/decoding to JSON.
|
|
10
11
|
|
|
@@ -2,11 +2,11 @@ class ValidationError(Exception):
|
|
|
2
2
|
"""Base class for ValidationError
|
|
3
3
|
Parameters:
|
|
4
4
|
key: Current Column key
|
|
5
|
-
msg: Validation error message
|
|
5
|
+
msg: Validation error message.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
def __init__(self, key: str, msg: str): # pragma: no cover
|
|
9
|
-
super().__init__("{}: {}"
|
|
9
|
+
super().__init__(f"{key}: {msg}")
|
|
10
10
|
self.key = key
|
|
11
11
|
self.msg = msg
|
|
12
12
|
|
|
@@ -17,15 +17,13 @@ from sqlalchemy_file.validators import Validator
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class File(BaseFile):
|
|
20
|
-
"""
|
|
21
|
-
Takes a file as content and uploads it to the appropriate storage
|
|
20
|
+
"""Takes a file as content and uploads it to the appropriate storage
|
|
22
21
|
according to the attached Column and file information into the
|
|
23
22
|
database as JSON.
|
|
24
23
|
|
|
25
24
|
Default attributes provided for all ``File`` include:
|
|
26
25
|
|
|
27
26
|
Attributes:
|
|
28
|
-
|
|
29
27
|
filename (str): This is the name of the uploaded file
|
|
30
28
|
file_id: This is the generated UUID for the uploaded file
|
|
31
29
|
upload_storage: Name of the storage used to save the uploaded file
|
|
@@ -69,7 +67,7 @@ class File(BaseFile):
|
|
|
69
67
|
self._thaw()
|
|
70
68
|
|
|
71
69
|
def apply_validators(self, validators: List[Validator], key: str = "") -> None:
|
|
72
|
-
"""Apply validators to current file"""
|
|
70
|
+
"""Apply validators to current file."""
|
|
73
71
|
for validator in validators:
|
|
74
72
|
validator.process(self, key)
|
|
75
73
|
|
|
@@ -78,13 +76,13 @@ class File(BaseFile):
|
|
|
78
76
|
processors: List[Processor],
|
|
79
77
|
upload_storage: Optional[str] = None,
|
|
80
78
|
) -> None:
|
|
81
|
-
"""Apply processors to current file"""
|
|
79
|
+
"""Apply processors to current file."""
|
|
82
80
|
for processor in processors:
|
|
83
81
|
processor.process(self, upload_storage)
|
|
84
82
|
self._freeze()
|
|
85
83
|
|
|
86
84
|
def save_to_storage(self, upload_storage: Optional[str] = None) -> None:
|
|
87
|
-
"""Save current file into provided `upload_storage
|
|
85
|
+
"""Save current file into provided `upload_storage`."""
|
|
88
86
|
extra = self.get("extra", {})
|
|
89
87
|
extra.update({"content_type": self.content_type})
|
|
90
88
|
|
|
@@ -93,6 +91,7 @@ class File(BaseFile):
|
|
|
93
91
|
warnings.warn(
|
|
94
92
|
'metadata attribute is deprecated. Use extra={"meta_data": ...} instead',
|
|
95
93
|
DeprecationWarning,
|
|
94
|
+
stacklevel=1,
|
|
96
95
|
)
|
|
97
96
|
extra.update({"meta_data": metadata})
|
|
98
97
|
|
|
@@ -111,7 +110,7 @@ class File(BaseFile):
|
|
|
111
110
|
self["file_id"] = stored_file.name
|
|
112
111
|
self["upload_storage"] = upload_storage
|
|
113
112
|
self["uploaded_at"] = datetime.utcnow().isoformat()
|
|
114
|
-
self["path"] = "{}/{
|
|
113
|
+
self["path"] = f"{upload_storage}/{stored_file.name}"
|
|
115
114
|
self["url"] = stored_file.get_cdn_url()
|
|
116
115
|
self["saved"] = True
|
|
117
116
|
|
|
@@ -125,7 +124,7 @@ class File(BaseFile):
|
|
|
125
124
|
headers: Optional[Dict[str, str]] = None,
|
|
126
125
|
) -> StoredFile:
|
|
127
126
|
"""Store content into provided `upload_storage`
|
|
128
|
-
with additional `metadata`. Can be
|
|
127
|
+
with additional `metadata`. Can be used by processors
|
|
129
128
|
to store additional files.
|
|
130
129
|
"""
|
|
131
130
|
name = name or str(uuid.uuid4())
|
|
@@ -137,7 +136,7 @@ class File(BaseFile):
|
|
|
137
136
|
extra=extra,
|
|
138
137
|
headers=headers,
|
|
139
138
|
)
|
|
140
|
-
self["files"].append("{}/{}"
|
|
139
|
+
self["files"].append(f"{upload_storage}/{name}")
|
|
141
140
|
return stored_file
|
|
142
141
|
|
|
143
142
|
def encode(self) -> Dict[str, Any]:
|
|
@@ -4,7 +4,7 @@ import os
|
|
|
4
4
|
import re
|
|
5
5
|
from builtins import RuntimeError
|
|
6
6
|
from tempfile import SpooledTemporaryFile
|
|
7
|
-
from typing import Any, Dict, Union
|
|
7
|
+
from typing import Any, Dict, List, Union
|
|
8
8
|
|
|
9
9
|
INMEMORY_FILESIZE = 1024 * 1024
|
|
10
10
|
LOCAL_STORAGE_DRIVER_NAME = "Local Storage"
|
|
@@ -18,7 +18,8 @@ def get_metadata_file_obj(metadata: Dict[str, Any]) -> "SpooledTemporaryFile[byt
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def get_content_from_file_obj(fileobj: Any) -> Any:
|
|
21
|
-
"""Provides a real file object from file content
|
|
21
|
+
"""Provides a real file object from file content.
|
|
22
|
+
|
|
22
23
|
Converts ``str`` and ``bytes`` to an actual file.
|
|
23
24
|
"""
|
|
24
25
|
if isinstance(fileobj, (str, bytes)):
|
|
@@ -26,7 +27,7 @@ def get_content_from_file_obj(fileobj: Any) -> Any:
|
|
|
26
27
|
f.write(fileobj.encode() if isinstance(fileobj, str) else fileobj)
|
|
27
28
|
f.seek(0)
|
|
28
29
|
return f
|
|
29
|
-
|
|
30
|
+
if getattr(fileobj, "file", None) is not None:
|
|
30
31
|
return fileobj.file
|
|
31
32
|
return fileobj
|
|
32
33
|
|
|
@@ -34,7 +35,7 @@ def get_content_from_file_obj(fileobj: Any) -> Any:
|
|
|
34
35
|
def get_filename_from_fileob(fileobj: Any) -> Any:
|
|
35
36
|
if getattr(fileobj, "filename", None) is not None:
|
|
36
37
|
return fileobj.filename
|
|
37
|
-
|
|
38
|
+
if getattr(fileobj, "name", None) is not None:
|
|
38
39
|
return os.path.basename(fileobj.name)
|
|
39
40
|
return "unnamed"
|
|
40
41
|
|
|
@@ -64,9 +65,7 @@ def get_content_size_from_fileobj(file: Any) -> Any:
|
|
|
64
65
|
|
|
65
66
|
def convert_size(size: Union[str, int]) -> int:
|
|
66
67
|
# convert size to number of bytes ex: 1k -> 1000; 1Ki->1024
|
|
67
|
-
if isinstance(size,
|
|
68
|
-
return size
|
|
69
|
-
elif isinstance(size, str):
|
|
68
|
+
if isinstance(size, str):
|
|
70
69
|
pattern = re.compile(r"^(\d+)\s*(k|([KM]i?))$")
|
|
71
70
|
m = re.fullmatch(pattern, size)
|
|
72
71
|
if m is None:
|
|
@@ -74,3 +73,9 @@ def convert_size(size: Union[str, int]) -> int:
|
|
|
74
73
|
value, si, _ = m.groups()
|
|
75
74
|
si_map = {"k": 1000, "K": 1000, "M": 1000**2, "Ki": 1024, "Mi": 1024**2}
|
|
76
75
|
return int(value) * si_map[si]
|
|
76
|
+
return size
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def flatmap(lists: List[List[Any]]) -> List[Any]:
|
|
80
|
+
"""Flattens a list of lists into a single list."""
|
|
81
|
+
return [value for _list in lists for value in _list]
|
|
@@ -7,8 +7,7 @@ T = TypeVar("T", bound=Any)
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class MutableList(Mutable, typing.List[T]):
|
|
10
|
-
"""
|
|
11
|
-
A list type that implements :class:`Mutable`.
|
|
10
|
+
"""A list type that implements :class:`Mutable`.
|
|
12
11
|
|
|
13
12
|
The :class:`MutableList` object implements a list that will
|
|
14
13
|
emit change events to the underlying mapping when the contents of
|
|
@@ -32,8 +31,7 @@ class MutableList(Mutable, typing.List[T]):
|
|
|
32
31
|
return MutableList(value)
|
|
33
32
|
# this call will raise ValueError
|
|
34
33
|
return Mutable.coerce(key, value) # pragma: no cover
|
|
35
|
-
|
|
36
|
-
return value # pragma: no cover
|
|
34
|
+
return value # pragma: no cover
|
|
37
35
|
|
|
38
36
|
@no_type_check
|
|
39
37
|
def __reduce_ex__(self, proto): # pragma: no cover
|
|
@@ -50,20 +48,14 @@ class MutableList(Mutable, typing.List[T]):
|
|
|
50
48
|
|
|
51
49
|
def __setitem__(self, index: Any, value: Any) -> None:
|
|
52
50
|
"""Detect list set events and emit change events."""
|
|
53
|
-
if isinstance(index, slice)
|
|
54
|
-
old_value = self[index]
|
|
55
|
-
else:
|
|
56
|
-
old_value = [self[index]]
|
|
51
|
+
old_value = self[index] if isinstance(index, slice) else [self[index]]
|
|
57
52
|
list.__setitem__(self, index, value)
|
|
58
53
|
self.changed()
|
|
59
54
|
self._removed.extend(old_value)
|
|
60
55
|
|
|
61
56
|
def __delitem__(self, index: Any) -> None:
|
|
62
57
|
"""Detect list del events and emit change events."""
|
|
63
|
-
if isinstance(index, slice)
|
|
64
|
-
old_value = self[index]
|
|
65
|
-
else:
|
|
66
|
-
old_value = [self[index]]
|
|
58
|
+
old_value = self[index] if isinstance(index, slice) else [self[index]]
|
|
67
59
|
list.__delitem__(self, index)
|
|
68
60
|
self.changed()
|
|
69
61
|
self._removed.extend(old_value)
|
|
@@ -10,8 +10,7 @@ if TYPE_CHECKING:
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class Processor:
|
|
13
|
-
"""
|
|
14
|
-
Interface that must be implemented by file processors.
|
|
13
|
+
"""Interface that must be implemented by file processors.
|
|
15
14
|
Can be used to add additional data to the stored file or change it.
|
|
16
15
|
When file processors are run the file has already been stored.
|
|
17
16
|
|
|
@@ -21,21 +20,18 @@ class Processor:
|
|
|
21
20
|
def process(
|
|
22
21
|
self, file: "File", upload_storage: Optional[str] = None
|
|
23
22
|
) -> None: # pragma: no cover
|
|
24
|
-
"""
|
|
25
|
-
Should be overridden in inherited class
|
|
23
|
+
"""Should be overridden in inherited class
|
|
26
24
|
Parameters:
|
|
27
25
|
file: [File][sqlalchemy_file.file.File] object,
|
|
28
26
|
Use file.original_content to access uploaded file
|
|
29
27
|
upload_storage: pass this to
|
|
30
28
|
[file.store_content()][sqlalchemy_file.file.File.store_content]
|
|
31
|
-
to attach additional files to the original file
|
|
29
|
+
to attach additional files to the original file.
|
|
32
30
|
"""
|
|
33
|
-
pass
|
|
34
31
|
|
|
35
32
|
|
|
36
33
|
class ThumbnailGenerator(Processor):
|
|
37
|
-
"""
|
|
38
|
-
Generate thumbnail from original content.
|
|
34
|
+
"""Generate thumbnail from original content.
|
|
39
35
|
|
|
40
36
|
The default thumbnail format and size are `PNG@128x128`, those can be changed
|
|
41
37
|
by giving custom `thumbnail_size` and `thumbnail_format`
|
|
@@ -124,7 +120,7 @@ class ThumbnailGenerator(Processor):
|
|
|
124
120
|
"height": height,
|
|
125
121
|
}
|
|
126
122
|
)
|
|
127
|
-
extra.update({"meta_data": metadata})
|
|
123
|
+
extra.update({"content_type": content_type, "meta_data": metadata})
|
|
128
124
|
stored_file = file.store_content(
|
|
129
125
|
output, upload_storage, extra=extra, headers=file.get("headers", None)
|
|
130
126
|
)
|
|
@@ -135,7 +131,7 @@ class ThumbnailGenerator(Processor):
|
|
|
135
131
|
"width": width,
|
|
136
132
|
"height": height,
|
|
137
133
|
"upload_storage": upload_storage,
|
|
138
|
-
"path": "{}/{
|
|
134
|
+
"path": f"{upload_storage}/{stored_file.name}",
|
|
139
135
|
"url": stored_file.get_cdn_url(),
|
|
140
136
|
}
|
|
141
137
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import warnings
|
|
2
|
-
from typing import Any, Dict, Iterator, Optional
|
|
3
|
+
from typing import Any, ClassVar, Dict, Iterator, Optional
|
|
3
4
|
|
|
4
5
|
from libcloud.storage.base import Container
|
|
5
6
|
from libcloud.storage.types import ObjectDoesNotExistError
|
|
@@ -8,8 +9,7 @@ from sqlalchemy_file.stored_file import StoredFile
|
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class StorageManager:
|
|
11
|
-
"""
|
|
12
|
-
Takes care of managing the whole Storage environment for the application.
|
|
12
|
+
"""Takes care of managing the whole Storage environment for the application.
|
|
13
13
|
|
|
14
14
|
Use [add_storage][sqlalchemy_file.storage.StorageManager.add_storage] method
|
|
15
15
|
to add new `libcloud.storage.base.Container`and associate a name which
|
|
@@ -23,46 +23,45 @@ class StorageManager:
|
|
|
23
23
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
-
_default_storage_name: Optional[str] = None
|
|
27
|
-
_storages: Dict[str, Container] = {}
|
|
26
|
+
_default_storage_name: ClassVar[Optional[str]] = None
|
|
27
|
+
_storages: ClassVar[Dict[str, Container]] = {}
|
|
28
28
|
|
|
29
29
|
@classmethod
|
|
30
30
|
def set_default(cls, name: str) -> None:
|
|
31
|
-
"""Replaces the current application default storage"""
|
|
31
|
+
"""Replaces the current application default storage."""
|
|
32
32
|
if name not in cls._storages:
|
|
33
|
-
raise RuntimeError("{} storage has not been added"
|
|
33
|
+
raise RuntimeError(f"{name} storage has not been added")
|
|
34
34
|
cls._default_storage_name = name
|
|
35
35
|
|
|
36
36
|
@classmethod
|
|
37
37
|
def get_default(cls) -> str:
|
|
38
|
-
"""Gets the current application default storage"""
|
|
38
|
+
"""Gets the current application default storage."""
|
|
39
39
|
if cls._default_storage_name is None:
|
|
40
40
|
raise RuntimeError("No default storage has been added")
|
|
41
41
|
return cls._default_storage_name
|
|
42
42
|
|
|
43
43
|
@classmethod
|
|
44
44
|
def add_storage(cls, name: str, container: Container) -> None:
|
|
45
|
-
"""Add new storage"""
|
|
45
|
+
"""Add new storage."""
|
|
46
46
|
assert isinstance(container, Container), "Invalid container"
|
|
47
47
|
if name in cls._storages:
|
|
48
|
-
raise RuntimeError("Storage {} has already been added"
|
|
48
|
+
raise RuntimeError(f"Storage {name} has already been added")
|
|
49
49
|
if cls._default_storage_name is None:
|
|
50
50
|
cls._default_storage_name = name
|
|
51
51
|
cls._storages[name] = container
|
|
52
52
|
|
|
53
53
|
@classmethod
|
|
54
54
|
def get(cls, name: Optional[str] = None) -> Container:
|
|
55
|
-
"""
|
|
56
|
-
|
|
57
|
-
return default if name isn't provided
|
|
55
|
+
"""Gets the container instance associate to the name,
|
|
56
|
+
return default if name isn't provided.
|
|
58
57
|
"""
|
|
59
58
|
if name is None and cls._default_storage_name is None:
|
|
60
59
|
raise RuntimeError("No default storage have been added")
|
|
61
|
-
|
|
60
|
+
if name is None:
|
|
62
61
|
name = cls._default_storage_name
|
|
63
62
|
if name in cls._storages:
|
|
64
63
|
return cls._storages[name]
|
|
65
|
-
raise RuntimeError("{} storage has not been added"
|
|
64
|
+
raise RuntimeError(f"{name} storage has not been added")
|
|
66
65
|
|
|
67
66
|
@classmethod
|
|
68
67
|
def save_file(
|
|
@@ -78,6 +77,7 @@ class StorageManager:
|
|
|
78
77
|
warnings.warn(
|
|
79
78
|
'metadata attribute is deprecated. Use extra={"meta_data": ...} instead',
|
|
80
79
|
DeprecationWarning,
|
|
80
|
+
stacklevel=1,
|
|
81
81
|
)
|
|
82
82
|
extra = {
|
|
83
83
|
"meta_data": metadata,
|
|
@@ -110,30 +110,28 @@ class StorageManager:
|
|
|
110
110
|
@classmethod
|
|
111
111
|
def get_file(cls, path: str) -> StoredFile:
|
|
112
112
|
"""Retrieve the file with `provided` path,
|
|
113
|
-
path is expected to be `storage_name/file_id
|
|
113
|
+
path is expected to be `storage_name/file_id`.
|
|
114
114
|
"""
|
|
115
115
|
upload_storage, file_id = path.split("/")
|
|
116
116
|
return StoredFile(StorageManager.get(upload_storage).get_object(file_id))
|
|
117
117
|
|
|
118
118
|
@classmethod
|
|
119
119
|
def delete_file(cls, path: str) -> bool:
|
|
120
|
-
"""Delete the file with `provided` path
|
|
121
|
-
|
|
120
|
+
"""Delete the file with `provided` path.
|
|
121
|
+
|
|
122
|
+
The path is expected to be `storage_name/file_id`.
|
|
122
123
|
"""
|
|
123
124
|
upload_storage, file_id = path.split("/")
|
|
124
125
|
obj = StorageManager.get(upload_storage).get_object(file_id)
|
|
125
126
|
if obj.driver.name == LOCAL_STORAGE_DRIVER_NAME:
|
|
126
127
|
"""Try deleting associated metadata file"""
|
|
127
|
-
|
|
128
|
+
with contextlib.suppress(ObjectDoesNotExistError):
|
|
128
129
|
obj.container.get_object(f"{obj.name}.metadata.json").delete()
|
|
129
|
-
|
|
130
|
-
pass
|
|
130
|
+
|
|
131
131
|
return obj.delete()
|
|
132
132
|
|
|
133
133
|
@classmethod
|
|
134
134
|
def _clear(cls) -> None:
|
|
135
|
-
"""
|
|
136
|
-
This is only for testing pourposes, resets the StorageManager
|
|
137
|
-
"""
|
|
135
|
+
"""This is only for testing pourposes, resets the StorageManager."""
|
|
138
136
|
cls._default_storage_name = None
|
|
139
137
|
cls._storages = {}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from libcloud.storage.base import Object
|
|
6
|
+
from libcloud.storage.types import ObjectDoesNotExistError
|
|
7
|
+
from sqlalchemy_file.helpers import LOCAL_STORAGE_DRIVER_NAME
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StoredFile(io.IOBase):
|
|
11
|
+
"""Represents a file that has been stored in a database. This class provides
|
|
12
|
+
a file-like interface for reading the file content.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
name: The name of the file (Same as file_id).
|
|
16
|
+
filename: The original name of the uploaded file.
|
|
17
|
+
content_type: The content type of the uploaded file.
|
|
18
|
+
object: The `Object` representing the file in the storage service.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, obj: Object) -> None:
|
|
22
|
+
if obj.driver.name == LOCAL_STORAGE_DRIVER_NAME:
|
|
23
|
+
"""Retrieve metadata from associated metadata file"""
|
|
24
|
+
try:
|
|
25
|
+
metadata_obj = obj.container.get_object(f"{obj.name}.metadata.json")
|
|
26
|
+
with open(metadata_obj.get_cdn_url()) as metadata_file:
|
|
27
|
+
obj.meta_data = json.load(metadata_file)
|
|
28
|
+
except ObjectDoesNotExistError:
|
|
29
|
+
pass
|
|
30
|
+
self.name = obj.name
|
|
31
|
+
self.filename = obj.meta_data.get("filename", "unnamed")
|
|
32
|
+
self.content_type = obj.extra.get(
|
|
33
|
+
"content_type",
|
|
34
|
+
obj.meta_data.get("content_type", "application/octet-stream"),
|
|
35
|
+
)
|
|
36
|
+
self.object = obj
|
|
37
|
+
|
|
38
|
+
def get_cdn_url(self) -> Optional[str]:
|
|
39
|
+
"""Retrieves the CDN URL of the file if available."""
|
|
40
|
+
try:
|
|
41
|
+
return self.object.get_cdn_url()
|
|
42
|
+
except NotImplementedError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def read(self, n: int = -1, chunk_size: Optional[int] = None) -> bytes:
|
|
46
|
+
"""Reads the content of the file.
|
|
47
|
+
|
|
48
|
+
Arguments:
|
|
49
|
+
n: The number of bytes to read. If not specified or set to -1,
|
|
50
|
+
it reads the entire content of the file. Defaults to -1.
|
|
51
|
+
chunk_size: The size of the chunks to read at a time.
|
|
52
|
+
If not specified, the default chunk size of the storage provider will be used.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
return next(
|
|
56
|
+
self.object.range_as_stream(
|
|
57
|
+
0, end_bytes=n if n > 0 else None, chunk_size=chunk_size
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
pass # No need to close;
|
|
63
|
+
|
|
64
|
+
def seekable(self) -> bool:
|
|
65
|
+
return False # Seeking is not supported ; pragma: no cover
|
|
66
|
+
|
|
67
|
+
def writable(self) -> bool:
|
|
68
|
+
return False # Writing is not supported ; pragma: no cover
|
|
69
|
+
|
|
70
|
+
def readable(self) -> bool:
|
|
71
|
+
return True # Reading is supported ; pragma: no cover
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
|
|
1
|
+
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple, Type, Union
|
|
2
2
|
|
|
3
3
|
from sqlalchemy import event, inspect, orm, types
|
|
4
4
|
from sqlalchemy.engine import Connection, Dialect
|
|
5
5
|
from sqlalchemy.orm import ColumnProperty, Mapper, Session, SessionTransaction
|
|
6
6
|
from sqlalchemy.orm.attributes import get_history
|
|
7
7
|
from sqlalchemy_file.file import File
|
|
8
|
+
from sqlalchemy_file.helpers import flatmap
|
|
8
9
|
from sqlalchemy_file.mutable_list import MutableList
|
|
9
10
|
from sqlalchemy_file.processors import Processor, ThumbnailGenerator
|
|
10
11
|
from sqlalchemy_file.storage import StorageManager
|
|
@@ -12,9 +13,7 @@ from sqlalchemy_file.validators import ImageValidator, Validator
|
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class FileField(types.TypeDecorator): # type: ignore
|
|
15
|
-
|
|
16
|
-
"""
|
|
17
|
-
Provides support for storing attachments to **SQLAlchemy** models.
|
|
16
|
+
"""Provides support for storing attachments to **SQLAlchemy** models.
|
|
18
17
|
|
|
19
18
|
[FileField][sqlalchemy_file.types.FileField] can be used as a Column type to
|
|
20
19
|
store files into the model. The actual file itself will be uploaded to a specific
|
|
@@ -34,7 +33,7 @@ class FileField(types.TypeDecorator): # type: ignore
|
|
|
34
33
|
"""
|
|
35
34
|
|
|
36
35
|
impl = types.JSON
|
|
37
|
-
cache_ok =
|
|
36
|
+
cache_ok = False
|
|
38
37
|
|
|
39
38
|
def __init__(
|
|
40
39
|
self,
|
|
@@ -48,18 +47,17 @@ class FileField(types.TypeDecorator): # type: ignore
|
|
|
48
47
|
headers: Optional[Dict[str, str]] = None,
|
|
49
48
|
**kwargs: Dict[str, Any],
|
|
50
49
|
) -> None:
|
|
51
|
-
"""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
headers = {'Access-Control-Allow-Origin': 'http://mozilla.com'}
|
|
50
|
+
"""Parameters:
|
|
51
|
+
upload_storage: storage to use
|
|
52
|
+
validators: List of validators to apply
|
|
53
|
+
processors: List of validators to apply
|
|
54
|
+
upload_type: File class to use, could be
|
|
55
|
+
used to set custom File class
|
|
56
|
+
multiple: Use this to save multiple files
|
|
57
|
+
extra: Extra attributes (driver specific)
|
|
58
|
+
headers: Additional request headers,
|
|
59
|
+
such as CORS headers. For example:
|
|
60
|
+
headers = {'Access-Control-Allow-Origin': 'http://mozilla.com'}.
|
|
63
61
|
"""
|
|
64
62
|
super().__init__(*args, **kwargs)
|
|
65
63
|
if processors is None:
|
|
@@ -85,7 +83,7 @@ class FileField(types.TypeDecorator): # type: ignore
|
|
|
85
83
|
raise ValueError(f"Expected {self.upload_type}, received: {type(value)}")
|
|
86
84
|
if self.multiple and not (
|
|
87
85
|
isinstance(value, list)
|
|
88
|
-
and all(
|
|
86
|
+
and all(isinstance(v, self.upload_type) for v in value)
|
|
89
87
|
): # pragma: no cover
|
|
90
88
|
raise ValueError(
|
|
91
89
|
f"Expected MutableList[{self.upload_type}], received: {type(value)}"
|
|
@@ -111,7 +109,7 @@ class ImageField(FileField):
|
|
|
111
109
|
but also validates that the uploaded object is a valid image.
|
|
112
110
|
"""
|
|
113
111
|
|
|
114
|
-
cache_ok =
|
|
112
|
+
cache_ok = False
|
|
115
113
|
|
|
116
114
|
def __init__(
|
|
117
115
|
self,
|
|
@@ -127,20 +125,19 @@ class ImageField(FileField):
|
|
|
127
125
|
headers: Optional[Dict[str, str]] = None,
|
|
128
126
|
**kwargs: Dict[str, Any],
|
|
129
127
|
) -> None:
|
|
130
|
-
"""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
extra: Extra attributes (driver specific)
|
|
128
|
+
"""Parameters
|
|
129
|
+
upload_storage: storage to use
|
|
130
|
+
image_validator: ImageField use default image
|
|
131
|
+
validator, Use this property to customize it.
|
|
132
|
+
thumbnail_size: If set, a thumbnail will be generated
|
|
133
|
+
from original image using [ThumbnailGenerator]
|
|
134
|
+
[sqlalchemy_file.processors.ThumbnailGenerator]
|
|
135
|
+
validators: List of additional validators to apply
|
|
136
|
+
processors: List of validators to apply
|
|
137
|
+
upload_type: File class to use, could be
|
|
138
|
+
used to set custom File class
|
|
139
|
+
multiple: Use this to save multiple files
|
|
140
|
+
extra: Extra attributes (driver specific).
|
|
144
141
|
"""
|
|
145
142
|
if validators is None:
|
|
146
143
|
validators = []
|
|
@@ -165,7 +162,7 @@ class ImageField(FileField):
|
|
|
165
162
|
|
|
166
163
|
|
|
167
164
|
class FileFieldSessionTracker:
|
|
168
|
-
mapped_entities: Dict[Type[Any], List[str]] = {}
|
|
165
|
+
mapped_entities: ClassVar[Dict[Type[Any], List[str]]] = {}
|
|
169
166
|
|
|
170
167
|
@classmethod
|
|
171
168
|
def delete_files(cls, paths: Set[str], ctx: str) -> None:
|
|
@@ -194,14 +191,14 @@ class FileFieldSessionTracker:
|
|
|
194
191
|
paths = []
|
|
195
192
|
for item in data:
|
|
196
193
|
if isinstance(item, list):
|
|
197
|
-
paths.extend([f["
|
|
194
|
+
paths.extend([f["files"] for f in item])
|
|
198
195
|
elif isinstance(item, File):
|
|
199
|
-
paths.append(item["
|
|
200
|
-
return paths
|
|
196
|
+
paths.append(item["files"])
|
|
197
|
+
return flatmap(paths)
|
|
201
198
|
|
|
202
199
|
@classmethod
|
|
203
200
|
def _mapper_configured(cls, mapper: Mapper, class_: Any) -> None: # type: ignore[type-arg]
|
|
204
|
-
"""Detect and listen all class with FileField Column"""
|
|
201
|
+
"""Detect and listen all class with FileField Column."""
|
|
205
202
|
for mapper_property in mapper.iterate_properties:
|
|
206
203
|
if isinstance(mapper_property, ColumnProperty) and isinstance(
|
|
207
204
|
mapper_property.columns[0].type, FileField
|
|
@@ -217,7 +214,7 @@ class FileFieldSessionTracker:
|
|
|
217
214
|
|
|
218
215
|
@classmethod
|
|
219
216
|
def _after_configured(cls) -> None:
|
|
220
|
-
for entity in cls.mapped_entities
|
|
217
|
+
for entity in cls.mapped_entities:
|
|
221
218
|
event.listen(entity, "before_insert", cls._before_insert)
|
|
222
219
|
event.listen(entity, "before_update", cls._before_update)
|
|
223
220
|
event.listen(entity, "after_update", cls._after_update)
|
|
@@ -225,21 +222,20 @@ class FileFieldSessionTracker:
|
|
|
225
222
|
|
|
226
223
|
@classmethod
|
|
227
224
|
def _after_commit(cls, session: Session) -> None:
|
|
228
|
-
"""After commit, old files are automatically deleted"""
|
|
225
|
+
"""After commit, old files are automatically deleted."""
|
|
229
226
|
cls.delete_files(getattr(session, "_old_files", set()), "after_commit")
|
|
230
227
|
cls.clear_session(session)
|
|
231
228
|
|
|
232
229
|
@classmethod
|
|
233
230
|
def _after_soft_rollback(cls, session: Session, _: SessionTransaction) -> None:
|
|
234
|
-
"""After rollback, new files are automatically deleted"""
|
|
231
|
+
"""After rollback, new files are automatically deleted."""
|
|
235
232
|
cls.delete_files(getattr(session, "_new_files", set()), "after_soft_rollback")
|
|
236
233
|
cls.clear_session(session)
|
|
237
234
|
|
|
238
235
|
@classmethod
|
|
239
236
|
def _after_delete(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
|
|
240
|
-
"""
|
|
241
|
-
|
|
242
|
-
them when after session is committed
|
|
237
|
+
"""After delete mark all linked files as old in order to delete
|
|
238
|
+
them when after session is committed.
|
|
243
239
|
"""
|
|
244
240
|
tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
|
|
245
241
|
for key in tracked_columns:
|
|
@@ -247,17 +243,18 @@ class FileFieldSessionTracker:
|
|
|
247
243
|
if value is not None:
|
|
248
244
|
cls.add_old_files_to_session(
|
|
249
245
|
inspect(obj).session,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
246
|
+
flatmap(
|
|
247
|
+
[
|
|
248
|
+
f["files"]
|
|
249
|
+
for f in (value if isinstance(value, list) else [value])
|
|
250
|
+
]
|
|
251
|
+
),
|
|
254
252
|
)
|
|
255
253
|
|
|
256
254
|
@classmethod
|
|
257
255
|
def _after_update(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
|
|
258
|
-
"""
|
|
259
|
-
|
|
260
|
-
in order to delete them when after session is committed
|
|
256
|
+
"""After update, mark all edited files as old
|
|
257
|
+
in order to delete them when after session is committed.
|
|
261
258
|
"""
|
|
262
259
|
tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
|
|
263
260
|
for key in tracked_columns:
|
|
@@ -268,8 +265,7 @@ class FileFieldSessionTracker:
|
|
|
268
265
|
|
|
269
266
|
@classmethod
|
|
270
267
|
def _before_update(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
|
|
271
|
-
"""
|
|
272
|
-
Before updating values, validate and save files. For multiple fields,
|
|
268
|
+
"""Before updating values, validate and save files. For multiple fields,
|
|
273
269
|
mark all removed files as old, as _removed attribute will be
|
|
274
270
|
reinitialised after update.
|
|
275
271
|
"""
|
|
@@ -287,13 +283,15 @@ class FileFieldSessionTracker:
|
|
|
287
283
|
)
|
|
288
284
|
if isinstance(value, MutableList):
|
|
289
285
|
_removed = getattr(value, "_removed", ())
|
|
290
|
-
cls.add_old_files_to_session(
|
|
286
|
+
cls.add_old_files_to_session(
|
|
287
|
+
session, flatmap([f["files"] for f in _removed])
|
|
288
|
+
)
|
|
291
289
|
|
|
292
290
|
@classmethod
|
|
293
291
|
def _before_insert(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
|
|
294
292
|
"""Before inserting values, mark all created files as new. They will be
|
|
295
|
-
automatically removed when session rollback
|
|
296
|
-
|
|
293
|
+
automatically removed when session rollback.
|
|
294
|
+
"""
|
|
297
295
|
tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
|
|
298
296
|
for key in tracked_columns:
|
|
299
297
|
value = getattr(obj, key)
|
|
@@ -308,14 +306,27 @@ class FileFieldSessionTracker:
|
|
|
308
306
|
def prepare_file_attr(
|
|
309
307
|
cls, mapper: Mapper, obj: Any, key: str # type: ignore[type-arg]
|
|
310
308
|
) -> Tuple[bool, Union[File, List[File]]]:
|
|
311
|
-
"""
|
|
312
|
-
|
|
313
|
-
|
|
309
|
+
"""Prepare file(s) for saving into the database by converting bytes or strings into
|
|
310
|
+
File objects, applying validators, uploading the file(s) to the upload storage,
|
|
311
|
+
and applying processors.
|
|
312
|
+
|
|
313
|
+
Arguments:
|
|
314
|
+
mapper (Mapper): The mapper instance associated with the object.
|
|
315
|
+
obj (Any): The object containing the file attribute.
|
|
316
|
+
key (str): The name of the file attribute.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Tuple[bool, Union[File, List[File]]]: A tuple containing a boolean flag indicating
|
|
320
|
+
whether the file(s) were changed, and the prepared file object(s). If the column type
|
|
321
|
+
allows multiple files, the prepared file objects are returned as a list.
|
|
314
322
|
"""
|
|
315
323
|
value = getattr(obj, key)
|
|
316
324
|
|
|
317
|
-
"""
|
|
318
|
-
|
|
325
|
+
"""
|
|
326
|
+
The `changed` flag becomes True in two cases:
|
|
327
|
+
1. For a single-field attribute, when a new file is assigned, replacing the previous file.
|
|
328
|
+
2. For a multiple-field attribute, when new file objects are added to the existing ones.
|
|
329
|
+
"""
|
|
319
330
|
changed = False
|
|
320
331
|
|
|
321
332
|
column_type = mapper.attrs.get(key).columns[0].type # type: ignore[misc,union-attr]
|
|
@@ -325,7 +336,7 @@ class FileFieldSessionTracker:
|
|
|
325
336
|
prepared_values: List[File] = []
|
|
326
337
|
for v in value if isinstance(value, list) else [value]:
|
|
327
338
|
if not isinstance(v, upload_type):
|
|
328
|
-
v = upload_type(v)
|
|
339
|
+
v = upload_type(v) # noqa: PLW2901
|
|
329
340
|
if not getattr(v, "saved", False):
|
|
330
341
|
changed = True
|
|
331
342
|
v.apply_validators(column_type.validators, key)
|
|
@@ -15,8 +15,8 @@ if TYPE_CHECKING:
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class Validator:
|
|
18
|
-
"""
|
|
19
|
-
|
|
18
|
+
"""Interface that must be implemented by file validators.
|
|
19
|
+
|
|
20
20
|
File validators get executed before a file is stored on the database
|
|
21
21
|
using one of the supported fields. Can be used to add additional data
|
|
22
22
|
to file object or change it.
|
|
@@ -25,19 +25,17 @@ class Validator:
|
|
|
25
25
|
|
|
26
26
|
@abstractmethod
|
|
27
27
|
def process(self, file: "File", attr_key: str) -> None: # pragma: no cover
|
|
28
|
-
"""
|
|
29
|
-
Should be overridden in inherited class
|
|
28
|
+
"""Should be overridden in inherited class.
|
|
30
29
|
|
|
31
30
|
Parameters:
|
|
32
31
|
file: [File][sqlalchemy_file.file.File] object
|
|
33
32
|
attr_key: current SQLAlchemy column key. Can be passed to
|
|
34
33
|
[ValidationError][sqlalchemy_file.exceptions.ValidationError]
|
|
35
34
|
"""
|
|
36
|
-
pass
|
|
37
35
|
|
|
38
36
|
|
|
39
37
|
class SizeValidator(Validator):
|
|
40
|
-
"""Validate file maximum size
|
|
38
|
+
"""Validate file maximum size.
|
|
41
39
|
|
|
42
40
|
Attributes:
|
|
43
41
|
max_size:
|
|
@@ -56,6 +54,7 @@ class SizeValidator(Validator):
|
|
|
56
54
|
|
|
57
55
|
For more information, view
|
|
58
56
|
[Wikipedia: Binary prefix](https://en.wikipedia.org/wiki/Binary_prefix)
|
|
57
|
+
|
|
59
58
|
Example:
|
|
60
59
|
```Python
|
|
61
60
|
class Attachment(Base):
|
|
@@ -79,8 +78,7 @@ class SizeValidator(Validator):
|
|
|
79
78
|
if file.size > convert_size(self.max_size):
|
|
80
79
|
raise SizeValidationError(
|
|
81
80
|
attr_key,
|
|
82
|
-
"The file is too large (
|
|
83
|
-
% (file.size, self.max_size),
|
|
81
|
+
f"The file is too large ({file.size} bytes). Allowed maximum size is {self.max_size}.",
|
|
84
82
|
)
|
|
85
83
|
|
|
86
84
|
|
|
@@ -119,13 +117,12 @@ class ContentTypeValidator(Validator):
|
|
|
119
117
|
):
|
|
120
118
|
raise ContentTypeValidationError(
|
|
121
119
|
attr_key,
|
|
122
|
-
"File content_type
|
|
123
|
-
% (file.content_type, self.allowed_content_types),
|
|
120
|
+
f"File content_type {file.content_type} is not allowed. Allowed content_types are: {self.allowed_content_types}",
|
|
124
121
|
)
|
|
125
122
|
|
|
126
123
|
|
|
127
124
|
class ImageValidator(ContentTypeValidator):
|
|
128
|
-
"""Default Validator for ImageField
|
|
125
|
+
"""Default Validator for ImageField.
|
|
129
126
|
|
|
130
127
|
Attributes:
|
|
131
128
|
min_wh: Minimum allowed dimension (w, h).
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import tempfile
|
|
3
|
-
from typing import Optional
|
|
4
|
-
|
|
5
|
-
from libcloud.storage.base import Object
|
|
6
|
-
from libcloud.storage.types import ObjectDoesNotExistError
|
|
7
|
-
from sqlalchemy_file.helpers import LOCAL_STORAGE_DRIVER_NAME
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class StoredFile:
|
|
11
|
-
def __init__(self, obj: Object) -> None:
|
|
12
|
-
if obj.driver.name == LOCAL_STORAGE_DRIVER_NAME:
|
|
13
|
-
"""Retrieve metadata from associated metadata file"""
|
|
14
|
-
try:
|
|
15
|
-
metadata_obj = obj.container.get_object(f"{obj.name}.metadata.json")
|
|
16
|
-
obj.meta_data = json.load(open(metadata_obj.get_cdn_url()))
|
|
17
|
-
except ObjectDoesNotExistError:
|
|
18
|
-
pass
|
|
19
|
-
self.name = obj.name
|
|
20
|
-
self.filename = obj.meta_data.get("filename", "unnamed")
|
|
21
|
-
self.content_type = obj.extra.get(
|
|
22
|
-
"content_type",
|
|
23
|
-
obj.meta_data.get("content_type", "application/octet-stream"),
|
|
24
|
-
)
|
|
25
|
-
self.object = obj
|
|
26
|
-
|
|
27
|
-
def get_cdn_url(self) -> Optional[str]:
|
|
28
|
-
try:
|
|
29
|
-
return self.object.get_cdn_url()
|
|
30
|
-
except NotImplementedError:
|
|
31
|
-
return None
|
|
32
|
-
|
|
33
|
-
def read(self, n: int = -1) -> bytes:
|
|
34
|
-
if self.object.driver.name == LOCAL_STORAGE_DRIVER_NAME:
|
|
35
|
-
return open(self.object.get_cdn_url(), "rb").read(n)
|
|
36
|
-
_file = tempfile.NamedTemporaryFile()
|
|
37
|
-
self.object.download(_file.name, overwrite_existing=True)
|
|
38
|
-
return _file.read(n)
|
|
File without changes
|
|
File without changes
|