sqlalchemy-file 0.4.0.dev0__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.dev0 → sqlalchemy_file-0.5.0}/.gitignore +2 -1
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/PKG-INFO +17 -18
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/README.md +4 -4
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/pyproject.toml +54 -19
- sqlalchemy_file-0.5.0/sqlalchemy_file/__init__.py +4 -0
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/base.py +3 -2
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/exceptions.py +2 -2
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/file.py +8 -9
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/helpers.py +12 -7
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/mutable_list.py +4 -12
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/processors.py +6 -10
- {sqlalchemy_file-0.4.0.dev0 → 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.dev0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/types.py +83 -74
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/sqlalchemy_file/validators.py +8 -11
- sqlalchemy_file-0.4.0.dev0/sqlalchemy_file/__init__.py +0 -5
- sqlalchemy_file-0.4.0.dev0/sqlalchemy_file/stored_file.py +0 -38
- {sqlalchemy_file-0.4.0.dev0 → sqlalchemy_file-0.5.0}/LICENSE +0 -0
- {sqlalchemy_file-0.4.0.dev0 → 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
|
|
@@ -27,28 +27,27 @@ Classifier: Topic :: Database :: Database Engines/Servers
|
|
|
27
27
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
28
28
|
Requires-Python: >=3.7
|
|
29
29
|
Requires-Dist: apache-libcloud<3.8,>=3.6
|
|
30
|
-
Requires-Dist: sqlalchemy<1
|
|
30
|
+
Requires-Dist: sqlalchemy<2.1,>=1.4
|
|
31
31
|
Provides-Extra: dev
|
|
32
|
-
Requires-Dist: pre-commit<
|
|
33
|
-
Requires-Dist: uvicorn<0.
|
|
32
|
+
Requires-Dist: pre-commit<4.0.0,>=2.20.0; extra == 'dev'
|
|
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==
|
|
39
|
-
Requires-Dist: coverage<7.
|
|
40
|
-
Requires-Dist: fastapi<0.
|
|
38
|
+
Requires-Dist: black==23.7.0; extra == 'test'
|
|
39
|
+
Requires-Dist: coverage<7.3.0,>=7.0.0; extra == 'test'
|
|
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==
|
|
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.
|
|
49
|
-
Requires-Dist: python-multipart==0.0.
|
|
50
|
-
Requires-Dist: ruff==0.0.
|
|
51
|
-
Requires-Dist: sqlmodel==0.0.8; extra == 'test'
|
|
48
|
+
Requires-Dist: pytest<7.5.0,>=7.2.0; extra == 'test'
|
|
49
|
+
Requires-Dist: python-multipart==0.0.6; extra == 'test'
|
|
50
|
+
Requires-Dist: ruff==0.0.278; extra == 'test'
|
|
52
51
|
Description-Content-Type: text/markdown
|
|
53
52
|
|
|
54
53
|
# sqlalchemy-file
|
|
@@ -154,8 +153,8 @@ class Attachment(Base):
|
|
|
154
153
|
|
|
155
154
|
|
|
156
155
|
# Configure Storage
|
|
157
|
-
os.makedirs("/
|
|
158
|
-
container = LocalStorageDriver("
|
|
156
|
+
os.makedirs("./upload_dir/attachment", 0o777, exist_ok=True)
|
|
157
|
+
container = LocalStorageDriver("./upload_dir").get_container("attachment")
|
|
159
158
|
StorageManager.add_storage("default", container)
|
|
160
159
|
|
|
161
160
|
# Save your model
|
|
@@ -164,8 +163,8 @@ engine = create_engine(
|
|
|
164
163
|
)
|
|
165
164
|
Base.metadata.create_all(engine)
|
|
166
165
|
|
|
167
|
-
with Session(engine) as session:
|
|
168
|
-
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))
|
|
169
168
|
session.add(Attachment(name="attachment2", content=b"Hello world"))
|
|
170
169
|
session.add(Attachment(name="attachment3", content="Hello world"))
|
|
171
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")
|
|
@@ -30,7 +30,7 @@ classifiers = [
|
|
|
30
30
|
"Topic :: Database :: Database Engines/Servers",
|
|
31
31
|
]
|
|
32
32
|
dependencies = [
|
|
33
|
-
"SQLAlchemy >=1.4, <1
|
|
33
|
+
"SQLAlchemy >=1.4, <2.1",
|
|
34
34
|
"apache-libcloud >=3.6, <3.8",
|
|
35
35
|
]
|
|
36
36
|
dynamic = ["version"]
|
|
@@ -43,28 +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 ==
|
|
48
|
-
"ruff ==0.0.
|
|
49
|
-
"black ==
|
|
50
|
-
"coverage >=7.0.0, <7.
|
|
46
|
+
"pytest >=7.2.0, <7.5.0",
|
|
47
|
+
"mypy ==1.4.1",
|
|
48
|
+
"ruff ==0.0.278",
|
|
49
|
+
"black ==23.7.0",
|
|
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, <
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"fastapi >=0.92, <0.93",
|
|
54
|
+
"Pillow >=9.4.0, <10.1.0",
|
|
55
|
+
"python-multipart ==0.0.6",
|
|
56
|
+
"fastapi >=0.92, <0.96",
|
|
58
57
|
"Flask >=2.2, <2.3",
|
|
59
58
|
"Flask-SQLAlchemy >=3.0,<3.1"
|
|
60
59
|
]
|
|
61
60
|
doc = [
|
|
62
61
|
"mkdocs-material >=9.0.0, <10.0.0",
|
|
63
|
-
"mkdocstrings[python] >=0.19.0, <0.
|
|
62
|
+
"mkdocstrings[python] >=0.19.0, <0.23.0"
|
|
64
63
|
]
|
|
65
64
|
dev = [
|
|
66
|
-
"pre-commit >=2.20.0, <
|
|
67
|
-
"uvicorn >=0.20.0, <0.
|
|
65
|
+
"pre-commit >=2.20.0, <4.0.0",
|
|
66
|
+
"uvicorn >=0.20.0, <0.22.0",
|
|
68
67
|
]
|
|
69
68
|
|
|
70
69
|
[tool.hatch.envs.default]
|
|
@@ -74,7 +73,7 @@ features = [
|
|
|
74
73
|
]
|
|
75
74
|
[tool.hatch.envs.default.scripts]
|
|
76
75
|
format = [
|
|
77
|
-
"ruff sqlalchemy_file tests
|
|
76
|
+
"ruff sqlalchemy_file tests --fix",
|
|
78
77
|
"black ."
|
|
79
78
|
]
|
|
80
79
|
|
|
@@ -88,13 +87,27 @@ lint = [
|
|
|
88
87
|
"ruff sqlalchemy_file tests",
|
|
89
88
|
"black . --check"
|
|
90
89
|
]
|
|
91
|
-
|
|
90
|
+
run = "coverage run -m pytest tests"
|
|
92
91
|
cov = [
|
|
93
92
|
"coverage combine",
|
|
94
93
|
"coverage report --show-missing",
|
|
95
94
|
"coverage xml"
|
|
96
95
|
]
|
|
97
96
|
|
|
97
|
+
[[tool.hatch.envs.test.matrix]]
|
|
98
|
+
sqla_version = ["1.4.x", "2.0.x"]
|
|
99
|
+
|
|
100
|
+
[tool.hatch.envs.test.overrides]
|
|
101
|
+
matrix.sqla_version.dependencies = [
|
|
102
|
+
{ value = "SQLAlchemy >=2.0, <2.1", if = ["2.0.x"] },
|
|
103
|
+
{ value = "SQLAlchemy >=1.4, <1.5", if = ["1.4.x"] },
|
|
104
|
+
{ value = "sqlmodel ==0.0.8", if = ["1.4.x"] },
|
|
105
|
+
]
|
|
106
|
+
matrix.sqla_version.scripts = [
|
|
107
|
+
{ key = "run", value = 'coverage run -m pytest tests --ignore=tests/test_sqlmodel.py', if = ["2.0.x"] },
|
|
108
|
+
{ key = "cov", value = '', if = ["2.0.x"] },
|
|
109
|
+
]
|
|
110
|
+
|
|
98
111
|
[tool.hatch.envs.docs]
|
|
99
112
|
features = [
|
|
100
113
|
"doc",
|
|
@@ -122,20 +135,42 @@ source = ["sqlalchemy_file", "tests"]
|
|
|
122
135
|
|
|
123
136
|
[tool.ruff]
|
|
124
137
|
select = [
|
|
138
|
+
"B", # flake8-bugbear
|
|
139
|
+
"C4", # flake8-comprehensions
|
|
140
|
+
"C90", # mccabe
|
|
141
|
+
"D", # pydocstyle
|
|
125
142
|
"E", # pycodestyle errors
|
|
126
|
-
"
|
|
143
|
+
"ERA", # eradicate
|
|
127
144
|
"F", # pyflakes
|
|
128
145
|
"I", # isort
|
|
129
|
-
"
|
|
130
|
-
"
|
|
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
|
|
131
157
|
]
|
|
132
|
-
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"
|
|
133
163
|
|
|
134
164
|
[tool.ruff.isort]
|
|
135
165
|
known-third-party = ["sqlalchemy_file"]
|
|
136
166
|
|
|
167
|
+
[tool.ruff.per-file-ignores]
|
|
168
|
+
"__init__.py" = ["F401"]
|
|
169
|
+
"docs_src/**" = ["N999"]
|
|
170
|
+
|
|
137
171
|
[tool.mypy]
|
|
138
172
|
strict = true
|
|
173
|
+
warn_unused_ignores = false
|
|
139
174
|
|
|
140
175
|
[tool.hatch.build.targets.wheel]
|
|
141
176
|
[tool.hatch.build.targets.sdist]
|
|
@@ -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:
|
|
@@ -190,20 +187,18 @@ class FileFieldSessionTracker:
|
|
|
190
187
|
session._old_files.update(paths) # type: ignore
|
|
191
188
|
|
|
192
189
|
@classmethod
|
|
193
|
-
def extract_files_from_history(
|
|
194
|
-
cls, data: List[Union[MutableList[File], File]]
|
|
195
|
-
) -> List[str]:
|
|
190
|
+
def extract_files_from_history(cls, data: Union[Tuple[()], List[Any]]) -> List[str]:
|
|
196
191
|
paths = []
|
|
197
192
|
for item in data:
|
|
198
193
|
if isinstance(item, list):
|
|
199
|
-
paths.extend([f["
|
|
194
|
+
paths.extend([f["files"] for f in item])
|
|
200
195
|
elif isinstance(item, File):
|
|
201
|
-
paths.append(item["
|
|
202
|
-
return paths
|
|
196
|
+
paths.append(item["files"])
|
|
197
|
+
return flatmap(paths)
|
|
203
198
|
|
|
204
199
|
@classmethod
|
|
205
|
-
def _mapper_configured(cls, mapper: Mapper, class_: Any) -> None:
|
|
206
|
-
"""Detect and listen all class with FileField Column"""
|
|
200
|
+
def _mapper_configured(cls, mapper: Mapper, class_: Any) -> None: # type: ignore[type-arg]
|
|
201
|
+
"""Detect and listen all class with FileField Column."""
|
|
207
202
|
for mapper_property in mapper.iterate_properties:
|
|
208
203
|
if isinstance(mapper_property, ColumnProperty) and isinstance(
|
|
209
204
|
mapper_property.columns[0].type, FileField
|
|
@@ -219,7 +214,7 @@ class FileFieldSessionTracker:
|
|
|
219
214
|
|
|
220
215
|
@classmethod
|
|
221
216
|
def _after_configured(cls) -> None:
|
|
222
|
-
for entity in cls.mapped_entities
|
|
217
|
+
for entity in cls.mapped_entities:
|
|
223
218
|
event.listen(entity, "before_insert", cls._before_insert)
|
|
224
219
|
event.listen(entity, "before_update", cls._before_update)
|
|
225
220
|
event.listen(entity, "after_update", cls._after_update)
|
|
@@ -227,21 +222,20 @@ class FileFieldSessionTracker:
|
|
|
227
222
|
|
|
228
223
|
@classmethod
|
|
229
224
|
def _after_commit(cls, session: Session) -> None:
|
|
230
|
-
"""After commit, old files are automatically deleted"""
|
|
225
|
+
"""After commit, old files are automatically deleted."""
|
|
231
226
|
cls.delete_files(getattr(session, "_old_files", set()), "after_commit")
|
|
232
227
|
cls.clear_session(session)
|
|
233
228
|
|
|
234
229
|
@classmethod
|
|
235
230
|
def _after_soft_rollback(cls, session: Session, _: SessionTransaction) -> None:
|
|
236
|
-
"""After rollback, new files are automatically deleted"""
|
|
231
|
+
"""After rollback, new files are automatically deleted."""
|
|
237
232
|
cls.delete_files(getattr(session, "_new_files", set()), "after_soft_rollback")
|
|
238
233
|
cls.clear_session(session)
|
|
239
234
|
|
|
240
235
|
@classmethod
|
|
241
|
-
def _after_delete(cls, mapper: Mapper, _: Connection, obj: Any) -> None:
|
|
242
|
-
"""
|
|
243
|
-
|
|
244
|
-
them when after session is committed
|
|
236
|
+
def _after_delete(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
|
|
237
|
+
"""After delete mark all linked files as old in order to delete
|
|
238
|
+
them when after session is committed.
|
|
245
239
|
"""
|
|
246
240
|
tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
|
|
247
241
|
for key in tracked_columns:
|
|
@@ -249,17 +243,18 @@ class FileFieldSessionTracker:
|
|
|
249
243
|
if value is not None:
|
|
250
244
|
cls.add_old_files_to_session(
|
|
251
245
|
inspect(obj).session,
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
246
|
+
flatmap(
|
|
247
|
+
[
|
|
248
|
+
f["files"]
|
|
249
|
+
for f in (value if isinstance(value, list) else [value])
|
|
250
|
+
]
|
|
251
|
+
),
|
|
256
252
|
)
|
|
257
253
|
|
|
258
254
|
@classmethod
|
|
259
|
-
def _after_update(cls, mapper: Mapper, _: Connection, obj: Any) -> None:
|
|
260
|
-
"""
|
|
261
|
-
|
|
262
|
-
in order to delete them when after session is committed
|
|
255
|
+
def _after_update(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
|
|
256
|
+
"""After update, mark all edited files as old
|
|
257
|
+
in order to delete them when after session is committed.
|
|
263
258
|
"""
|
|
264
259
|
tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
|
|
265
260
|
for key in tracked_columns:
|
|
@@ -269,9 +264,8 @@ class FileFieldSessionTracker:
|
|
|
269
264
|
)
|
|
270
265
|
|
|
271
266
|
@classmethod
|
|
272
|
-
def _before_update(cls, mapper: Mapper, _: Connection, obj: Any) -> None:
|
|
273
|
-
"""
|
|
274
|
-
Before updating values, validate and save files. For multiple fields,
|
|
267
|
+
def _before_update(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
|
|
268
|
+
"""Before updating values, validate and save files. For multiple fields,
|
|
275
269
|
mark all removed files as old, as _removed attribute will be
|
|
276
270
|
reinitialised after update.
|
|
277
271
|
"""
|
|
@@ -289,13 +283,15 @@ class FileFieldSessionTracker:
|
|
|
289
283
|
)
|
|
290
284
|
if isinstance(value, MutableList):
|
|
291
285
|
_removed = getattr(value, "_removed", ())
|
|
292
|
-
cls.add_old_files_to_session(
|
|
286
|
+
cls.add_old_files_to_session(
|
|
287
|
+
session, flatmap([f["files"] for f in _removed])
|
|
288
|
+
)
|
|
293
289
|
|
|
294
290
|
@classmethod
|
|
295
|
-
def _before_insert(cls, mapper: Mapper, _: Connection, obj: Any) -> None:
|
|
291
|
+
def _before_insert(cls, mapper: Mapper, _: Connection, obj: Any) -> None: # type: ignore[type-arg]
|
|
296
292
|
"""Before inserting values, mark all created files as new. They will be
|
|
297
|
-
automatically removed when session rollback
|
|
298
|
-
|
|
293
|
+
automatically removed when session rollback.
|
|
294
|
+
"""
|
|
299
295
|
tracked_columns: List[str] = cls.mapped_entities.get(mapper.class_, [])
|
|
300
296
|
for key in tracked_columns:
|
|
301
297
|
value = getattr(obj, key)
|
|
@@ -308,26 +304,39 @@ class FileFieldSessionTracker:
|
|
|
308
304
|
|
|
309
305
|
@classmethod
|
|
310
306
|
def prepare_file_attr(
|
|
311
|
-
cls, mapper: Mapper, obj: Any, key: str
|
|
307
|
+
cls, mapper: Mapper, obj: Any, key: str # type: ignore[type-arg]
|
|
312
308
|
) -> Tuple[bool, Union[File, List[File]]]:
|
|
313
|
-
"""
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
316
322
|
"""
|
|
317
323
|
value = getattr(obj, key)
|
|
318
324
|
|
|
319
|
-
"""
|
|
320
|
-
|
|
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
|
+
"""
|
|
321
330
|
changed = False
|
|
322
331
|
|
|
323
|
-
column_type = mapper.attrs.get(key).columns[0].type
|
|
332
|
+
column_type = mapper.attrs.get(key).columns[0].type # type: ignore[misc,union-attr]
|
|
324
333
|
assert isinstance(column_type, FileField)
|
|
325
334
|
upload_type = column_type.upload_type
|
|
326
335
|
|
|
327
336
|
prepared_values: List[File] = []
|
|
328
337
|
for v in value if isinstance(value, list) else [value]:
|
|
329
338
|
if not isinstance(v, upload_type):
|
|
330
|
-
v = upload_type(v)
|
|
339
|
+
v = upload_type(v) # noqa: PLW2901
|
|
331
340
|
if not getattr(v, "saved", False):
|
|
332
341
|
changed = True
|
|
333
342
|
v.apply_validators(column_type.validators, key)
|
|
@@ -351,8 +360,8 @@ class FileFieldSessionTracker:
|
|
|
351
360
|
|
|
352
361
|
@classmethod
|
|
353
362
|
def setup(cls) -> None:
|
|
354
|
-
event.listen(orm.
|
|
355
|
-
event.listen(orm.
|
|
363
|
+
event.listen(orm.Mapper, "mapper_configured", cls._mapper_configured)
|
|
364
|
+
event.listen(orm.Mapper, "after_configured", cls._after_configured)
|
|
356
365
|
event.listen(Session, "after_commit", cls._after_commit)
|
|
357
366
|
event.listen(Session, "after_soft_rollback", cls._after_soft_rollback)
|
|
358
367
|
|
|
@@ -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
|