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.
Files changed (92) hide show
  1. async_orm_kit/__init__.py +151 -0
  2. async_orm_kit/_logging.py +58 -0
  3. async_orm_kit/_version.py +1 -0
  4. async_orm_kit/config.py +31 -0
  5. async_orm_kit/exceptions.py +353 -0
  6. async_orm_kit/integrations/__init__.py +15 -0
  7. async_orm_kit/integrations/alembic.py +56 -0
  8. async_orm_kit/integrations/fastapi.py +134 -0
  9. async_orm_kit/integrations/storage.py +213 -0
  10. async_orm_kit/manager/__init__.py +3 -0
  11. async_orm_kit/manager/manager.py +568 -0
  12. async_orm_kit/models/__init__.py +3 -0
  13. async_orm_kit/models/base.py +568 -0
  14. async_orm_kit/models/fields/__init__.py +23 -0
  15. async_orm_kit/models/fields/exceptions.py +10 -0
  16. async_orm_kit/models/fields/typed/__init__.py +11 -0
  17. async_orm_kit/models/fields/typed/base.py +155 -0
  18. async_orm_kit/models/fields/typed/file.py +697 -0
  19. async_orm_kit/models/fields/typed/string.py +38 -0
  20. async_orm_kit/models/fields/validators.py +110 -0
  21. async_orm_kit/models/meta.py +181 -0
  22. async_orm_kit/models/mixins.py +19 -0
  23. async_orm_kit/observers/__init__.py +4 -0
  24. async_orm_kit/observers/mixin.py +26 -0
  25. async_orm_kit/observers/model_observer.py +20 -0
  26. async_orm_kit/observers/queryset_observer.py +38 -0
  27. async_orm_kit/py.typed +0 -0
  28. async_orm_kit/query/__init__.py +0 -0
  29. async_orm_kit/query/aggregations/__init__.py +8 -0
  30. async_orm_kit/query/aggregations/avg.py +71 -0
  31. async_orm_kit/query/aggregations/base.py +317 -0
  32. async_orm_kit/query/aggregations/count.py +115 -0
  33. async_orm_kit/query/aggregations/max.py +76 -0
  34. async_orm_kit/query/aggregations/min.py +76 -0
  35. async_orm_kit/query/aggregations/sum.py +73 -0
  36. async_orm_kit/query/compiler.py +574 -0
  37. async_orm_kit/query/debugger.py +269 -0
  38. async_orm_kit/query/expressions/__init__.py +62 -0
  39. async_orm_kit/query/expressions/analysis.py +56 -0
  40. async_orm_kit/query/expressions/case.py +321 -0
  41. async_orm_kit/query/expressions/collections.py +251 -0
  42. async_orm_kit/query/expressions/f.py +362 -0
  43. async_orm_kit/query/expressions/functions.py +202 -0
  44. async_orm_kit/query/expressions/subquery.py +206 -0
  45. async_orm_kit/query/expressions/value.py +101 -0
  46. async_orm_kit/query/expressions/window.py +95 -0
  47. async_orm_kit/query/lookups/__init__.py +121 -0
  48. async_orm_kit/query/lookups/conditions.py +315 -0
  49. async_orm_kit/query/lookups/correlated.py +86 -0
  50. async_orm_kit/query/lookups/json_paths.py +167 -0
  51. async_orm_kit/query/lookups/parser.py +276 -0
  52. async_orm_kit/query/lookups/resolver/__init__.py +5 -0
  53. async_orm_kit/query/lookups/resolver/context.py +150 -0
  54. async_orm_kit/query/lookups/resolver/resolver.py +572 -0
  55. async_orm_kit/query/lookups/resolver/types.py +150 -0
  56. async_orm_kit/query/mixins/__init__.py +0 -0
  57. async_orm_kit/query/mixins/combinable.py +376 -0
  58. async_orm_kit/query/mixins/orderable.py +208 -0
  59. async_orm_kit/query/observability.py +346 -0
  60. async_orm_kit/query/planning/__init__.py +0 -0
  61. async_orm_kit/query/planning/directives.py +117 -0
  62. async_orm_kit/query/planning/factory.py +40 -0
  63. async_orm_kit/query/planning/passes/__init__.py +15 -0
  64. async_orm_kit/query/planning/passes/base.py +47 -0
  65. async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
  66. async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
  67. async_orm_kit/query/planning/passes/join_promotion.py +81 -0
  68. async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
  69. async_orm_kit/query/planning/passes/redundant_path.py +67 -0
  70. async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
  71. async_orm_kit/query/planning/planner.py +160 -0
  72. async_orm_kit/query/planning/protocols.py +78 -0
  73. async_orm_kit/query/postgres/__init__.py +0 -0
  74. async_orm_kit/query/postgres/optimizer.py +189 -0
  75. async_orm_kit/query/q.py +596 -0
  76. async_orm_kit/query/relations/__init__.py +0 -0
  77. async_orm_kit/query/relations/enums.py +16 -0
  78. async_orm_kit/query/relations/graph.py +908 -0
  79. async_orm_kit/query/relations/node.py +421 -0
  80. async_orm_kit/queryset/__init__.py +4 -0
  81. async_orm_kit/queryset/builder.py +1259 -0
  82. async_orm_kit/queryset/compound.py +379 -0
  83. async_orm_kit/queryset/executor.py +718 -0
  84. async_orm_kit/queryset/queryset.py +1726 -0
  85. async_orm_kit/queryset/state.py +164 -0
  86. async_orm_kit/queryset/validators.py +361 -0
  87. async_orm_kit/queryset/writer.py +755 -0
  88. async_orm_kit/types.py +23 -0
  89. async_orm_kit-1.0.0.dist-info/METADATA +318 -0
  90. async_orm_kit-1.0.0.dist-info/RECORD +92 -0
  91. async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
  92. 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