async-orm-kit 1.0.0__py3-none-any.whl
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.
- async_orm_kit/__init__.py +151 -0
- async_orm_kit/_logging.py +58 -0
- async_orm_kit/_version.py +1 -0
- async_orm_kit/config.py +31 -0
- async_orm_kit/exceptions.py +353 -0
- async_orm_kit/integrations/__init__.py +15 -0
- async_orm_kit/integrations/alembic.py +56 -0
- async_orm_kit/integrations/fastapi.py +134 -0
- async_orm_kit/integrations/storage.py +213 -0
- async_orm_kit/manager/__init__.py +3 -0
- async_orm_kit/manager/manager.py +568 -0
- async_orm_kit/models/__init__.py +3 -0
- async_orm_kit/models/base.py +568 -0
- async_orm_kit/models/fields/__init__.py +23 -0
- async_orm_kit/models/fields/exceptions.py +10 -0
- async_orm_kit/models/fields/typed/__init__.py +11 -0
- async_orm_kit/models/fields/typed/base.py +155 -0
- async_orm_kit/models/fields/typed/file.py +697 -0
- async_orm_kit/models/fields/typed/string.py +38 -0
- async_orm_kit/models/fields/validators.py +110 -0
- async_orm_kit/models/meta.py +181 -0
- async_orm_kit/models/mixins.py +19 -0
- async_orm_kit/observers/__init__.py +4 -0
- async_orm_kit/observers/mixin.py +26 -0
- async_orm_kit/observers/model_observer.py +20 -0
- async_orm_kit/observers/queryset_observer.py +38 -0
- async_orm_kit/py.typed +0 -0
- async_orm_kit/query/__init__.py +0 -0
- async_orm_kit/query/aggregations/__init__.py +8 -0
- async_orm_kit/query/aggregations/avg.py +71 -0
- async_orm_kit/query/aggregations/base.py +317 -0
- async_orm_kit/query/aggregations/count.py +115 -0
- async_orm_kit/query/aggregations/max.py +76 -0
- async_orm_kit/query/aggregations/min.py +76 -0
- async_orm_kit/query/aggregations/sum.py +73 -0
- async_orm_kit/query/compiler.py +574 -0
- async_orm_kit/query/debugger.py +269 -0
- async_orm_kit/query/expressions/__init__.py +62 -0
- async_orm_kit/query/expressions/analysis.py +56 -0
- async_orm_kit/query/expressions/case.py +321 -0
- async_orm_kit/query/expressions/collections.py +251 -0
- async_orm_kit/query/expressions/f.py +362 -0
- async_orm_kit/query/expressions/functions.py +202 -0
- async_orm_kit/query/expressions/subquery.py +206 -0
- async_orm_kit/query/expressions/value.py +101 -0
- async_orm_kit/query/expressions/window.py +95 -0
- async_orm_kit/query/lookups/__init__.py +121 -0
- async_orm_kit/query/lookups/conditions.py +315 -0
- async_orm_kit/query/lookups/correlated.py +86 -0
- async_orm_kit/query/lookups/json_paths.py +167 -0
- async_orm_kit/query/lookups/parser.py +276 -0
- async_orm_kit/query/lookups/resolver/__init__.py +5 -0
- async_orm_kit/query/lookups/resolver/context.py +150 -0
- async_orm_kit/query/lookups/resolver/resolver.py +572 -0
- async_orm_kit/query/lookups/resolver/types.py +150 -0
- async_orm_kit/query/mixins/__init__.py +0 -0
- async_orm_kit/query/mixins/combinable.py +376 -0
- async_orm_kit/query/mixins/orderable.py +208 -0
- async_orm_kit/query/observability.py +346 -0
- async_orm_kit/query/planning/__init__.py +0 -0
- async_orm_kit/query/planning/directives.py +117 -0
- async_orm_kit/query/planning/factory.py +40 -0
- async_orm_kit/query/planning/passes/__init__.py +15 -0
- async_orm_kit/query/planning/passes/base.py +47 -0
- async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
- async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
- async_orm_kit/query/planning/passes/join_promotion.py +81 -0
- async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
- async_orm_kit/query/planning/passes/redundant_path.py +67 -0
- async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
- async_orm_kit/query/planning/planner.py +160 -0
- async_orm_kit/query/planning/protocols.py +78 -0
- async_orm_kit/query/postgres/__init__.py +0 -0
- async_orm_kit/query/postgres/optimizer.py +189 -0
- async_orm_kit/query/q.py +596 -0
- async_orm_kit/query/relations/__init__.py +0 -0
- async_orm_kit/query/relations/enums.py +16 -0
- async_orm_kit/query/relations/graph.py +908 -0
- async_orm_kit/query/relations/node.py +421 -0
- async_orm_kit/queryset/__init__.py +4 -0
- async_orm_kit/queryset/builder.py +1259 -0
- async_orm_kit/queryset/compound.py +379 -0
- async_orm_kit/queryset/executor.py +718 -0
- async_orm_kit/queryset/queryset.py +1726 -0
- async_orm_kit/queryset/state.py +164 -0
- async_orm_kit/queryset/validators.py +361 -0
- async_orm_kit/queryset/writer.py +755 -0
- async_orm_kit/types.py +23 -0
- async_orm_kit-1.0.0.dist-info/METADATA +318 -0
- async_orm_kit-1.0.0.dist-info/RECORD +92 -0
- async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
- async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import AsyncIterator, Callable
|
|
5
|
+
from typing import IO, Any, Protocol, cast
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.types import String
|
|
8
|
+
|
|
9
|
+
from async_orm_kit.models.fields.exceptions import FieldValidationError
|
|
10
|
+
from async_orm_kit.models.fields.typed.base import BaseValidatedType, ValidatorType
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StorageBackend(Protocol):
|
|
16
|
+
"""Structural contract required by the legacy async FileField adapter.
|
|
17
|
+
|
|
18
|
+
A concrete backend is injected by the application. The ORM deliberately
|
|
19
|
+
does not create or own a default storage instance.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
async def save(
|
|
23
|
+
self, name: str, content: Any, *, max_length: int | None = None
|
|
24
|
+
) -> str: ...
|
|
25
|
+
async def open(self, name: str, mode: str = "rb") -> IO[bytes]: ...
|
|
26
|
+
async def delete(self, name: str) -> None: ...
|
|
27
|
+
async def exists(self, name: str) -> bool: ...
|
|
28
|
+
async def size(self, name: str) -> int: ...
|
|
29
|
+
def generate_filename(self, name: str) -> str: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_file_like(value: Any) -> bool:
|
|
33
|
+
"""
|
|
34
|
+
Minimal heuristic for 'file-like':
|
|
35
|
+
- Starlette UploadFile
|
|
36
|
+
- Has .read and .name attributes
|
|
37
|
+
Customize as needed for your app.
|
|
38
|
+
"""
|
|
39
|
+
return isinstance(value, (io.IOBase, io.BufferedIOBase)) or (
|
|
40
|
+
hasattr(value, "read") and callable(value.read) and hasattr(value, "name")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FieldFile:
|
|
45
|
+
"""
|
|
46
|
+
Descriptor for file attributes on model instances.
|
|
47
|
+
Provides Django-like file interface with lazy loading.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, instance, field: "FileField", name: str | None):
|
|
51
|
+
self.instance = instance
|
|
52
|
+
self.field = field
|
|
53
|
+
self.storage = field.storage
|
|
54
|
+
self._name = name
|
|
55
|
+
self._file: Any = None
|
|
56
|
+
self._committed = True
|
|
57
|
+
|
|
58
|
+
def __str__(self):
|
|
59
|
+
return self.name or ""
|
|
60
|
+
|
|
61
|
+
def __bool__(self):
|
|
62
|
+
return bool(self.name)
|
|
63
|
+
|
|
64
|
+
def __eq__(self, other):
|
|
65
|
+
if hasattr(other, "name"):
|
|
66
|
+
return self.name == other.name
|
|
67
|
+
return self.name == other
|
|
68
|
+
|
|
69
|
+
def __hash__(self):
|
|
70
|
+
return hash(self.name)
|
|
71
|
+
|
|
72
|
+
def _require_file(self):
|
|
73
|
+
"""Raise ValueError if no file is associated with this field."""
|
|
74
|
+
if not self:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"The '{self.field.attname}' attribute has no file associated with it."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _required_name(self) -> str:
|
|
80
|
+
self._require_file()
|
|
81
|
+
return cast(str, self.name)
|
|
82
|
+
|
|
83
|
+
def _get_file(self):
|
|
84
|
+
self._require_file()
|
|
85
|
+
if self._file is None:
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
"FileField storage is asynchronous; call `await field.open()` first"
|
|
88
|
+
)
|
|
89
|
+
return self._file
|
|
90
|
+
|
|
91
|
+
def _require_storage(self) -> StorageBackend:
|
|
92
|
+
storage = self.storage
|
|
93
|
+
if storage is None:
|
|
94
|
+
raise RuntimeError(
|
|
95
|
+
"FileField requires an explicitly configured storage backend. "
|
|
96
|
+
"Pass `storage=` when declaring the field."
|
|
97
|
+
)
|
|
98
|
+
return storage
|
|
99
|
+
|
|
100
|
+
def _set_file(self, file):
|
|
101
|
+
self._file = file
|
|
102
|
+
|
|
103
|
+
def _del_file(self):
|
|
104
|
+
del self._file
|
|
105
|
+
|
|
106
|
+
file = property(_get_file, _set_file, _del_file)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def name(self) -> str | None:
|
|
110
|
+
return self._name
|
|
111
|
+
|
|
112
|
+
@name.setter
|
|
113
|
+
def name(self, value):
|
|
114
|
+
self._name = value
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def path(self) -> str:
|
|
118
|
+
"""Get local filesystem path (only for filesystem storage)"""
|
|
119
|
+
self._require_file()
|
|
120
|
+
storage = self._require_storage()
|
|
121
|
+
path = getattr(storage, "path", None)
|
|
122
|
+
if not callable(path):
|
|
123
|
+
raise NotImplementedError("The configured storage has no local path")
|
|
124
|
+
return path(self.name)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def url(self) -> str:
|
|
128
|
+
"""Get public URL for the file"""
|
|
129
|
+
self._require_file()
|
|
130
|
+
storage = self._require_storage()
|
|
131
|
+
url = getattr(storage, "url", None)
|
|
132
|
+
if not callable(url):
|
|
133
|
+
raise NotImplementedError(
|
|
134
|
+
"The configured storage does not expose public URLs"
|
|
135
|
+
)
|
|
136
|
+
return url(self.name)
|
|
137
|
+
|
|
138
|
+
async def get_presigned_url(self, expire: int = 3600) -> str:
|
|
139
|
+
"""Get temporary signed URL (for cloud storage)"""
|
|
140
|
+
self._require_file()
|
|
141
|
+
storage = self._require_storage()
|
|
142
|
+
method = getattr(storage, "get_presigned_url", None)
|
|
143
|
+
if not callable(method):
|
|
144
|
+
raise NotImplementedError(
|
|
145
|
+
"The configured storage has no presigned URL support"
|
|
146
|
+
)
|
|
147
|
+
return await method(self.name, expire=expire)
|
|
148
|
+
|
|
149
|
+
async def size(self) -> int:
|
|
150
|
+
"""
|
|
151
|
+
Get file size asynchronously.
|
|
152
|
+
For uncommitted files, returns the size of the in-memory file object.
|
|
153
|
+
For committed files, queries the storage backend.
|
|
154
|
+
"""
|
|
155
|
+
self._require_file()
|
|
156
|
+
if not self._committed:
|
|
157
|
+
# Uncommitted file - get size from file object
|
|
158
|
+
if self._file is not None:
|
|
159
|
+
if hasattr(self._file, "size"):
|
|
160
|
+
return self._file.size
|
|
161
|
+
# Fallback: seek to end to get size
|
|
162
|
+
current_pos = self._file.tell()
|
|
163
|
+
self._file.seek(0, 2) # Seek to end
|
|
164
|
+
size = self._file.tell()
|
|
165
|
+
self._file.seek(current_pos) # Restore position
|
|
166
|
+
return size
|
|
167
|
+
return 0
|
|
168
|
+
# Committed file - get size from storage
|
|
169
|
+
return await self._require_storage().size(self._required_name())
|
|
170
|
+
|
|
171
|
+
async def open(self, mode: str = "rb") -> IO[bytes]:
|
|
172
|
+
"""
|
|
173
|
+
Open the file from storage.
|
|
174
|
+
If the file is already open, reopen it in the specified mode.
|
|
175
|
+
"""
|
|
176
|
+
self._require_file()
|
|
177
|
+
|
|
178
|
+
if self._file is None:
|
|
179
|
+
self._file = await self._require_storage().open(self._required_name(), mode)
|
|
180
|
+
else:
|
|
181
|
+
# File exists but may be closed - reopen it
|
|
182
|
+
if hasattr(self._file, "open"):
|
|
183
|
+
self._file.open(mode)
|
|
184
|
+
else:
|
|
185
|
+
# File doesn't support reopening; release the old handle first.
|
|
186
|
+
self.close()
|
|
187
|
+
self._file = await self._require_storage().open(
|
|
188
|
+
self._required_name(), mode
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return self._file
|
|
192
|
+
|
|
193
|
+
async def read(self) -> bytes:
|
|
194
|
+
"""Read the complete stored object into memory.
|
|
195
|
+
|
|
196
|
+
For large files, prefer :meth:`iter_chunks` to keep memory bounded.
|
|
197
|
+
"""
|
|
198
|
+
self._require_file()
|
|
199
|
+
method = getattr(self._require_storage(), "read", None)
|
|
200
|
+
if not callable(method):
|
|
201
|
+
file_obj = self._file
|
|
202
|
+
if file_obj is None or getattr(file_obj, "closed", False):
|
|
203
|
+
file_obj = await self.open("rb")
|
|
204
|
+
current = file_obj.tell()
|
|
205
|
+
try:
|
|
206
|
+
file_obj.seek(0)
|
|
207
|
+
return file_obj.read()
|
|
208
|
+
finally:
|
|
209
|
+
file_obj.seek(current)
|
|
210
|
+
return await method(self.name)
|
|
211
|
+
|
|
212
|
+
async def iter_chunks(self, chunk_size: int = 256 * 1024) -> AsyncIterator[bytes]:
|
|
213
|
+
"""Stream a stored object in bounded chunks when supported by storage."""
|
|
214
|
+
self._require_file()
|
|
215
|
+
method = getattr(self._require_storage(), "iter_chunks", None)
|
|
216
|
+
if not callable(method):
|
|
217
|
+
raise NotImplementedError(
|
|
218
|
+
"The configured storage does not support streaming downloads"
|
|
219
|
+
)
|
|
220
|
+
iterator = method(self.name, chunk_size=chunk_size)
|
|
221
|
+
try:
|
|
222
|
+
async for chunk in iterator:
|
|
223
|
+
yield chunk
|
|
224
|
+
finally:
|
|
225
|
+
close = getattr(iterator, "aclose", None)
|
|
226
|
+
if callable(close):
|
|
227
|
+
await close()
|
|
228
|
+
|
|
229
|
+
async def metadata(self) -> Any:
|
|
230
|
+
"""Return backend metadata for this stored object."""
|
|
231
|
+
self._require_file()
|
|
232
|
+
method = getattr(self._require_storage(), "metadata", None)
|
|
233
|
+
if not callable(method):
|
|
234
|
+
raise NotImplementedError(
|
|
235
|
+
"The configured storage has no metadata capability"
|
|
236
|
+
)
|
|
237
|
+
return await method(self.name)
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def closed(self) -> bool:
|
|
241
|
+
"""Check if the file is closed."""
|
|
242
|
+
file = getattr(self, "_file", None)
|
|
243
|
+
return file is None or getattr(file, "closed", True)
|
|
244
|
+
|
|
245
|
+
def close(self):
|
|
246
|
+
"""Close the file if it's open."""
|
|
247
|
+
file = getattr(self, "_file", None)
|
|
248
|
+
if file is not None:
|
|
249
|
+
file.close()
|
|
250
|
+
|
|
251
|
+
def _set_instance_attribute(self, name: str | None) -> None:
|
|
252
|
+
"""Update the mapped string and retain this FieldFile in the cache."""
|
|
253
|
+
if self.instance is None:
|
|
254
|
+
return
|
|
255
|
+
setattr(
|
|
256
|
+
self.instance, self.field.attname, name
|
|
257
|
+
) if self.field.attname is not None else None
|
|
258
|
+
cache_name = f"_{self.field.attname}_file"
|
|
259
|
+
setattr(self.instance, cache_name, self)
|
|
260
|
+
|
|
261
|
+
async def store(self, name: str, content: Any) -> str:
|
|
262
|
+
"""Upload content and update the mapped filename without a DB commit.
|
|
263
|
+
|
|
264
|
+
Storage and database transactions are independent. The caller remains
|
|
265
|
+
responsible for flushing/committing the model and compensating for an
|
|
266
|
+
uploaded object if the database transaction later fails.
|
|
267
|
+
"""
|
|
268
|
+
if not isinstance(name, str) or not name.strip():
|
|
269
|
+
raise ValueError("A non-empty filename is required")
|
|
270
|
+
if content is None:
|
|
271
|
+
raise ValueError("File content must not be None")
|
|
272
|
+
|
|
273
|
+
# Always run the field's sync and async validators through the same
|
|
274
|
+
# public validation pipeline. The previous async_validation/_validate
|
|
275
|
+
# branch referenced attributes that BaseValidatedType does not define.
|
|
276
|
+
validated_content = await self.field.clean(
|
|
277
|
+
content,
|
|
278
|
+
model_instance=self.instance,
|
|
279
|
+
)
|
|
280
|
+
generated_name = self.field.generate_filename(self.instance, name)
|
|
281
|
+
if len(generated_name) > self.field.max_length:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"Generated storage name exceeds FileField max_length "
|
|
284
|
+
f"({self.field.max_length})."
|
|
285
|
+
)
|
|
286
|
+
saved_name = await self._require_storage().save(
|
|
287
|
+
generated_name,
|
|
288
|
+
validated_content,
|
|
289
|
+
max_length=self.field.max_length,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
self._name = saved_name
|
|
293
|
+
self._file = None
|
|
294
|
+
self._committed = True
|
|
295
|
+
self._set_instance_attribute(saved_name)
|
|
296
|
+
return saved_name
|
|
297
|
+
|
|
298
|
+
async def save(self, name: str, content: Any) -> str:
|
|
299
|
+
"""Django-inspired alias for :meth:`store` (does not commit the model)."""
|
|
300
|
+
return await self.store(name, content)
|
|
301
|
+
|
|
302
|
+
async def delete(self) -> None:
|
|
303
|
+
"""Delete the stored object and clear the mapped filename.
|
|
304
|
+
|
|
305
|
+
Storage errors propagate. Clearing the database attribute after a failed
|
|
306
|
+
deletion would hide an orphaned object and make retries impossible.
|
|
307
|
+
This operation does not commit the caller's database transaction.
|
|
308
|
+
"""
|
|
309
|
+
if not self:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
name = self._required_name()
|
|
313
|
+
self.close()
|
|
314
|
+
try:
|
|
315
|
+
await self._require_storage().delete(name)
|
|
316
|
+
except Exception:
|
|
317
|
+
logger.exception("Failed to delete stored file %s", name)
|
|
318
|
+
raise
|
|
319
|
+
|
|
320
|
+
self._name = None
|
|
321
|
+
self._file = None
|
|
322
|
+
self._committed = False
|
|
323
|
+
self._set_instance_attribute(None)
|
|
324
|
+
|
|
325
|
+
async def exists(self) -> bool:
|
|
326
|
+
"""Check if file exists in storage"""
|
|
327
|
+
if not self.name:
|
|
328
|
+
return False
|
|
329
|
+
return await self._require_storage().exists(self.name)
|
|
330
|
+
|
|
331
|
+
async def get_accessed_time(self):
|
|
332
|
+
"""Get last accessed time"""
|
|
333
|
+
self._require_file()
|
|
334
|
+
method = getattr(self._require_storage(), "get_accessed_time", None)
|
|
335
|
+
if not callable(method):
|
|
336
|
+
raise NotImplementedError(
|
|
337
|
+
"The configured storage has no accessed-time metadata"
|
|
338
|
+
)
|
|
339
|
+
return await method(self.name)
|
|
340
|
+
|
|
341
|
+
async def get_created_time(self):
|
|
342
|
+
"""Get creation time"""
|
|
343
|
+
self._require_file()
|
|
344
|
+
method = getattr(self._require_storage(), "get_created_time", None)
|
|
345
|
+
if not callable(method):
|
|
346
|
+
raise NotImplementedError(
|
|
347
|
+
"The configured storage has no created-time metadata"
|
|
348
|
+
)
|
|
349
|
+
return await method(self.name)
|
|
350
|
+
|
|
351
|
+
async def get_modified_time(self):
|
|
352
|
+
"""Get last modified time"""
|
|
353
|
+
self._require_file()
|
|
354
|
+
method = getattr(self._require_storage(), "get_modified_time", None)
|
|
355
|
+
if not callable(method):
|
|
356
|
+
raise NotImplementedError(
|
|
357
|
+
"The configured storage has no modified-time metadata"
|
|
358
|
+
)
|
|
359
|
+
return await method(self.name)
|
|
360
|
+
|
|
361
|
+
def __getstate__(self):
|
|
362
|
+
"""
|
|
363
|
+
Pickle support: save only essential state.
|
|
364
|
+
The FileDescriptor will restore the rest on unpickling.
|
|
365
|
+
"""
|
|
366
|
+
return {
|
|
367
|
+
"name": self.name,
|
|
368
|
+
"closed": False,
|
|
369
|
+
"_committed": True,
|
|
370
|
+
"_file": None,
|
|
371
|
+
"instance": self.instance,
|
|
372
|
+
"field": self.field,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
def __setstate__(self, state):
|
|
376
|
+
"""Restore state from pickle."""
|
|
377
|
+
state = dict(state)
|
|
378
|
+
self._name = state.pop("name")
|
|
379
|
+
self.__dict__.update(state)
|
|
380
|
+
self.storage = self.field.storage
|
|
381
|
+
|
|
382
|
+
async def __aenter__(self):
|
|
383
|
+
return await self.open()
|
|
384
|
+
|
|
385
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
386
|
+
self.close()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class FileDescriptor:
|
|
390
|
+
"""
|
|
391
|
+
Descriptor that provides FieldFile interface on model instances
|
|
392
|
+
while preserving SQLAlchemy query capabilities.
|
|
393
|
+
|
|
394
|
+
- SQLAlchemy sees only the raw string column.
|
|
395
|
+
- Python code sees a FieldFile instance.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
def __init__(self, field: "FileField", instrumented_attr):
|
|
399
|
+
self.field = field
|
|
400
|
+
self._instrumented_attr = instrumented_attr # Original SQLAlchemy attribute
|
|
401
|
+
self.cache_attr_name = f"_{self.field.attname}_file"
|
|
402
|
+
|
|
403
|
+
def __get__(self, instance, owner=None):
|
|
404
|
+
if instance is None:
|
|
405
|
+
# Class-level access: give SQLAlchemy back its InstrumentedAttribute
|
|
406
|
+
return self._instrumented_attr
|
|
407
|
+
|
|
408
|
+
# Raw value from the mapped column (string path, None, maybe file-like)
|
|
409
|
+
raw_value = self._instrumented_attr.__get__(instance, owner)
|
|
410
|
+
|
|
411
|
+
cached = getattr(instance, self.cache_attr_name, None)
|
|
412
|
+
|
|
413
|
+
# Fast path: we already have a FieldFile whose name matches the DB value
|
|
414
|
+
if isinstance(cached, FieldFile) and (
|
|
415
|
+
not cached._committed or cached.name == raw_value
|
|
416
|
+
):
|
|
417
|
+
if cached.instance is not instance:
|
|
418
|
+
cached.instance = instance
|
|
419
|
+
return cached
|
|
420
|
+
|
|
421
|
+
# Case 1: string path or None → new FieldFile
|
|
422
|
+
if isinstance(raw_value, str) or raw_value is None:
|
|
423
|
+
file_obj = self.field.attr_class(instance, self.field, raw_value)
|
|
424
|
+
setattr(instance, self.cache_attr_name, file_obj)
|
|
425
|
+
return file_obj
|
|
426
|
+
|
|
427
|
+
# Case 2: raw_value is already a FieldFile (for example, assigned manually).
|
|
428
|
+
if isinstance(raw_value, FieldFile):
|
|
429
|
+
raw_value.instance = instance
|
|
430
|
+
raw_value.field = self.field
|
|
431
|
+
raw_value.storage = self.field.storage
|
|
432
|
+
setattr(instance, self.cache_attr_name, raw_value)
|
|
433
|
+
return raw_value
|
|
434
|
+
|
|
435
|
+
# Case 3: raw_value is a file-like object (UploadFile, open file, etc.)
|
|
436
|
+
if is_file_like(raw_value):
|
|
437
|
+
# Wrap as "uncommitted" FieldFile.
|
|
438
|
+
name = getattr(raw_value, "name", None)
|
|
439
|
+
file_obj = self.field.attr_class(instance, self.field, name)
|
|
440
|
+
file_obj._file = raw_value
|
|
441
|
+
file_obj._committed = False
|
|
442
|
+
|
|
443
|
+
# IMPORTANT: we do *not* write to the DB column here.
|
|
444
|
+
# Persisting a name/path must happen via FieldFile.store(), which
|
|
445
|
+
# calls setattr(instance, field.attname, self.name) after upload.
|
|
446
|
+
setattr(instance, self.cache_attr_name, file_obj)
|
|
447
|
+
return file_obj
|
|
448
|
+
|
|
449
|
+
# Fallback: unexpected type → treat as a name-like string
|
|
450
|
+
file_obj = self.field.attr_class(instance, self.field, str(raw_value))
|
|
451
|
+
setattr(instance, self.cache_attr_name, file_obj)
|
|
452
|
+
return file_obj
|
|
453
|
+
|
|
454
|
+
def __set__(self, instance, value):
|
|
455
|
+
"""
|
|
456
|
+
Setting the attribute updates both:
|
|
457
|
+
- the underlying SQLAlchemy column (string path), and
|
|
458
|
+
- the in-memory FieldFile cache (if applicable).
|
|
459
|
+
"""
|
|
460
|
+
cache_attr_name = self.cache_attr_name
|
|
461
|
+
|
|
462
|
+
# 1) FieldFile: store its name in DB, cache the object
|
|
463
|
+
if isinstance(value, FieldFile):
|
|
464
|
+
# Ensure FieldFile is bound correctly
|
|
465
|
+
value.instance = instance
|
|
466
|
+
value.field = self.field
|
|
467
|
+
value.storage = self.field.storage
|
|
468
|
+
value._committed = False # Maybe a handmade FieldFile
|
|
469
|
+
|
|
470
|
+
self._instrumented_attr.__set__(instance, value.name)
|
|
471
|
+
setattr(instance, cache_attr_name, value)
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
# 2) str or None: write directly to DB, clear cache
|
|
475
|
+
if isinstance(value, str) or value is None:
|
|
476
|
+
self._instrumented_attr.__set__(instance, value)
|
|
477
|
+
setattr(instance, cache_attr_name, None)
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
# 3) raw file-like: wrap into FieldFile; mark uncommitted;
|
|
481
|
+
# DO NOT auto-generate a name or touch storage here.
|
|
482
|
+
# The caller should call `await instance.<field>.store(...)`
|
|
483
|
+
# to upload and update DB.
|
|
484
|
+
if is_file_like(value):
|
|
485
|
+
name = getattr(value, "name", None)
|
|
486
|
+
file_obj = self.field.attr_class(instance, self.field, name)
|
|
487
|
+
file_obj._file = value
|
|
488
|
+
file_obj._committed = False
|
|
489
|
+
|
|
490
|
+
# Do not touch the DB column yet. The DB should only see a
|
|
491
|
+
# stable path after storage has accepted the file.
|
|
492
|
+
setattr(instance, cache_attr_name, file_obj)
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
# 4) Anything else is invalid
|
|
496
|
+
raise TypeError(
|
|
497
|
+
f"Invalid value for FileField {self.field.attname}: {type(value)!r}"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def __delete__(self, instance):
|
|
501
|
+
# Delegate to SQLAlchemy's deleter and clear our cache
|
|
502
|
+
self._instrumented_attr.__delete__(instance)
|
|
503
|
+
if hasattr(instance, self.cache_attr_name):
|
|
504
|
+
delattr(instance, self.cache_attr_name)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class FileField(BaseValidatedType):
|
|
508
|
+
"""
|
|
509
|
+
SQLAlchemy FileField with Django-like behavior and async validation support.
|
|
510
|
+
|
|
511
|
+
A storage backend is explicitly injected. Use
|
|
512
|
+
``AsyncStorageKitAdapter`` from ``async_orm_kit.integrations.storage`` to
|
|
513
|
+
integrate the separately installed ``async-storage-kit`` package.
|
|
514
|
+
|
|
515
|
+
Features:
|
|
516
|
+
- Async storage backend integration
|
|
517
|
+
- File upload with collision handling
|
|
518
|
+
- Lazy file loading via FieldFile descriptor
|
|
519
|
+
- Explicit storage cleanup through FieldFile.delete()
|
|
520
|
+
- URL generation (public and presigned)
|
|
521
|
+
- File metadata access (size, timestamps)
|
|
522
|
+
- Path traversal protection
|
|
523
|
+
- Custom upload path generation
|
|
524
|
+
|
|
525
|
+
.. code-block:: python
|
|
526
|
+
|
|
527
|
+
class User(Base):
|
|
528
|
+
avatar = Column(FileField(
|
|
529
|
+
upload_to="avatars/%Y/%m/",
|
|
530
|
+
validators=[max_file_size(5*1024*1024)],
|
|
531
|
+
max_length=255
|
|
532
|
+
))
|
|
533
|
+
|
|
534
|
+
# Upload file (storage write; the caller owns the DB transaction)
|
|
535
|
+
user = User()
|
|
536
|
+
await user.avatar.save("profile.jpg", file_content)
|
|
537
|
+
session.add(user)
|
|
538
|
+
await session.commit()
|
|
539
|
+
|
|
540
|
+
# Access file
|
|
541
|
+
url = user.avatar.url
|
|
542
|
+
size = await user.avatar.size()
|
|
543
|
+
|
|
544
|
+
# Delete the object and clear the mapped name
|
|
545
|
+
await user.avatar.delete()
|
|
546
|
+
await session.commit()
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
impl = String
|
|
550
|
+
cache_ok = True
|
|
551
|
+
descriptor_class = FileDescriptor
|
|
552
|
+
attr_class = FieldFile
|
|
553
|
+
|
|
554
|
+
def __init__(
|
|
555
|
+
self,
|
|
556
|
+
*,
|
|
557
|
+
upload_to: str | Callable = "",
|
|
558
|
+
storage: StorageBackend | None = None,
|
|
559
|
+
max_length: int = 255,
|
|
560
|
+
validators: list[ValidatorType] | None = None,
|
|
561
|
+
**kwargs,
|
|
562
|
+
):
|
|
563
|
+
"""
|
|
564
|
+
Args:
|
|
565
|
+
upload_to: Upload path or callable(instance, filename) -> path
|
|
566
|
+
storage: Explicit async storage backend. No global default is created.
|
|
567
|
+
max_length: Max filename/path length stored in DB
|
|
568
|
+
validators: List of validators (sync or async)
|
|
569
|
+
"""
|
|
570
|
+
self.upload_to = upload_to
|
|
571
|
+
self.storage = storage
|
|
572
|
+
self.max_length = max_length
|
|
573
|
+
|
|
574
|
+
super().__init__(validators=validators, length=max_length, **kwargs)
|
|
575
|
+
|
|
576
|
+
def _require_storage(self) -> StorageBackend:
|
|
577
|
+
if self.storage is None:
|
|
578
|
+
raise RuntimeError(
|
|
579
|
+
"FileField requires an explicitly configured storage backend. "
|
|
580
|
+
"Pass `storage=` when declaring the field."
|
|
581
|
+
)
|
|
582
|
+
return self.storage
|
|
583
|
+
|
|
584
|
+
def _validate_upload_path(self, path: str) -> str:
|
|
585
|
+
"""Validate and normalize a relative POSIX storage prefix on every OS."""
|
|
586
|
+
if not isinstance(path, str):
|
|
587
|
+
raise TypeError("upload_to must resolve to a string")
|
|
588
|
+
normalized = path.replace("\\", "/")
|
|
589
|
+
if not normalized:
|
|
590
|
+
return ""
|
|
591
|
+
if (
|
|
592
|
+
normalized.startswith("/")
|
|
593
|
+
or re.match(r"^[a-zA-Z]:", normalized)
|
|
594
|
+
or any(ord(char) < 32 or ord(char) == 127 for char in normalized)
|
|
595
|
+
):
|
|
596
|
+
raise ValueError("Absolute paths and control characters are not allowed")
|
|
597
|
+
|
|
598
|
+
# A trailing slash is conventional for upload directories; internal
|
|
599
|
+
# empty components and dot/traversal components are never accepted.
|
|
600
|
+
normalized = normalized.rstrip("/")
|
|
601
|
+
components = normalized.split("/")
|
|
602
|
+
if not normalized or any(part in {"", ".", ".."} for part in components):
|
|
603
|
+
raise ValueError("Unsafe upload_to path component")
|
|
604
|
+
return "/".join(components)
|
|
605
|
+
|
|
606
|
+
def generate_filename(self, instance, filename: str) -> str:
|
|
607
|
+
"""Generate a platform-independent relative storage key."""
|
|
608
|
+
if not isinstance(filename, str) or not filename.strip():
|
|
609
|
+
raise ValueError("A non-empty filename is required")
|
|
610
|
+
|
|
611
|
+
if callable(self.upload_to):
|
|
612
|
+
upload_path = self.upload_to(instance, filename)
|
|
613
|
+
else:
|
|
614
|
+
from datetime import datetime
|
|
615
|
+
|
|
616
|
+
upload_path = datetime.now().strftime(str(self.upload_to))
|
|
617
|
+
|
|
618
|
+
upload_path = self._validate_upload_path(upload_path)
|
|
619
|
+
|
|
620
|
+
# Client upload names may contain a browser-supplied path. Keep only
|
|
621
|
+
# the basename; storage.generate_filename() performs backend filename
|
|
622
|
+
# normalization (when available), and save() validates the final key.
|
|
623
|
+
portable_filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
|
624
|
+
if portable_filename in {"", ".", ".."}:
|
|
625
|
+
raise ValueError("Filename does not contain a usable basename")
|
|
626
|
+
full_path = (
|
|
627
|
+
f"{upload_path}/{portable_filename}" if upload_path else portable_filename
|
|
628
|
+
)
|
|
629
|
+
safe_input = self._validate_upload_path(full_path)
|
|
630
|
+
generated_name = self._require_storage().generate_filename(safe_input)
|
|
631
|
+
return self._validate_upload_path(generated_name)
|
|
632
|
+
|
|
633
|
+
def contribute_to_class(self, cls, name, instrumented_attr):
|
|
634
|
+
"""Install FileDescriptor that wraps InstrumentedAttribute."""
|
|
635
|
+
super().contribute_to_class(cls, name, instrumented_attr)
|
|
636
|
+
cls._file_fields[name] = self
|
|
637
|
+
descriptor = self.descriptor_class(self, instrumented_attr)
|
|
638
|
+
setattr(cls, name, descriptor)
|
|
639
|
+
|
|
640
|
+
def process_bind_param(self, value, dialect):
|
|
641
|
+
"""
|
|
642
|
+
SQLAlchemy bind parameter processing.
|
|
643
|
+
Handles file upload and sync validation.
|
|
644
|
+
For async validators, call clean() explicitly before saving.
|
|
645
|
+
"""
|
|
646
|
+
if value is None:
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
# If it's a FieldFile, extract the name (its already saved)
|
|
650
|
+
if isinstance(value, FieldFile):
|
|
651
|
+
return value.name
|
|
652
|
+
|
|
653
|
+
# File object - upload and validate
|
|
654
|
+
if is_file_like(value):
|
|
655
|
+
raise ValueError(
|
|
656
|
+
"Cannot assign file objects directly. "
|
|
657
|
+
"Use: await instance.<file_field>.store('name.jpg', file_obj)"
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# For simple string paths (e.g., manual assignment), apply intrinsic
|
|
661
|
+
# validation (e.g., max_length) and return resulting DB value.
|
|
662
|
+
return super().process_bind_param(value, dialect)
|
|
663
|
+
|
|
664
|
+
def process_result_value(self, value, dialect):
|
|
665
|
+
"""
|
|
666
|
+
Process value loaded from database.
|
|
667
|
+
Returns the raw path string (FieldFile wrapper is handled by descriptor).
|
|
668
|
+
"""
|
|
669
|
+
return value
|
|
670
|
+
|
|
671
|
+
async def clean(self, value, model_instance=None):
|
|
672
|
+
"""
|
|
673
|
+
Full validation for file values.
|
|
674
|
+
|
|
675
|
+
This method does not upload files. Uploads use ``FieldFile.save()``.
|
|
676
|
+
It accepts ``None``, a previously saved ``FieldFile``, or a string key.
|
|
677
|
+
File objects are rejected by ``process_bind_param``.
|
|
678
|
+
"""
|
|
679
|
+
if value is None:
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
# # If it's already our managed wrapper, we don't re-validate the path here.
|
|
683
|
+
# if isinstance(value, FieldFile):
|
|
684
|
+
# return value
|
|
685
|
+
|
|
686
|
+
try:
|
|
687
|
+
# Use BaseValidatedType pipeline:
|
|
688
|
+
# validate() -> run_validators() -> arun_validators()
|
|
689
|
+
cleaned = await super().clean(value, model_instance=model_instance)
|
|
690
|
+
return cleaned
|
|
691
|
+
except FieldValidationError:
|
|
692
|
+
raise
|
|
693
|
+
except Exception as e:
|
|
694
|
+
field_name = self.attname or "file"
|
|
695
|
+
raise FieldValidationError(
|
|
696
|
+
f"Validation failed for file field '{field_name}': {e}"
|
|
697
|
+
) from e
|