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,134 @@
|
|
|
1
|
+
"""Optional FastAPI integration for request-scoped SQLAlchemy sessions.
|
|
2
|
+
|
|
3
|
+
FastAPI is imported lazily by the exception-handler registration function.
|
|
4
|
+
Importing this module does not require the optional FastAPI package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncIterator, Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from async_orm_kit._logging import get_logger
|
|
15
|
+
from async_orm_kit.exceptions import (
|
|
16
|
+
DatabaseError,
|
|
17
|
+
MultipleObjectsReturned,
|
|
18
|
+
ObjectDoesNotExist,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
SessionFactory = Callable[[], AsyncSession]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_session_dependency(
|
|
27
|
+
session_factory: SessionFactory,
|
|
28
|
+
) -> Callable[[], AsyncIterator[AsyncSession]]:
|
|
29
|
+
"""Create a FastAPI yield dependency for one request-scoped AsyncSession.
|
|
30
|
+
|
|
31
|
+
The dependency closes the session after the request and rolls back an
|
|
32
|
+
active transaction when request execution raises or is cancelled. It does
|
|
33
|
+
not commit successful requests: transaction ownership remains explicit and
|
|
34
|
+
belongs to the application/service layer.
|
|
35
|
+
|
|
36
|
+
A SQLAlchemy AsyncSession is not safe for concurrent use by multiple tasks.
|
|
37
|
+
The dependency creates a fresh session for every request; do not pass the
|
|
38
|
+
yielded session into concurrently running tasks.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
session_factory: A callable such as SQLAlchemy's async_sessionmaker.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
An async-generator dependency suitable for ``Depends(...)``.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
TypeError: If ``session_factory`` is not callable.
|
|
48
|
+
"""
|
|
49
|
+
if not callable(session_factory):
|
|
50
|
+
raise TypeError("session_factory must be callable")
|
|
51
|
+
|
|
52
|
+
async def dependency() -> AsyncIterator[AsyncSession]:
|
|
53
|
+
async with session_factory() as session:
|
|
54
|
+
try:
|
|
55
|
+
yield session
|
|
56
|
+
except BaseException:
|
|
57
|
+
# Roll back only the transaction owned by this session. Preserve
|
|
58
|
+
# the original exception/cancellation if rollback itself fails.
|
|
59
|
+
if session.in_transaction():
|
|
60
|
+
try:
|
|
61
|
+
await session.rollback()
|
|
62
|
+
except Exception:
|
|
63
|
+
# Session context exit still closes the connection. The
|
|
64
|
+
# original request failure is more useful to propagate.
|
|
65
|
+
logger.exception("Request session rollback failed")
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
dependency.__name__ = "get_async_orm_session"
|
|
69
|
+
dependency.__qualname__ = "get_async_orm_session"
|
|
70
|
+
return dependency
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def register_exception_handlers(app: Any) -> Any:
|
|
74
|
+
"""Register safe HTTP mappings for common Async ORM Kit exceptions.
|
|
75
|
+
|
|
76
|
+
Defaults:
|
|
77
|
+
* ``ObjectDoesNotExist`` -> HTTP 404.
|
|
78
|
+
* ``MultipleObjectsReturned`` -> HTTP 409.
|
|
79
|
+
* ``DatabaseError`` -> HTTP 500 with no database details disclosed.
|
|
80
|
+
|
|
81
|
+
Query construction errors are deliberately not translated globally because
|
|
82
|
+
they may indicate programming errors rather than invalid client input.
|
|
83
|
+
Applications can register more specific handlers for their own API contract.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
app: A FastAPI application instance.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The same application instance, allowing setup chaining.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ImportError: If the optional FastAPI dependency is not installed.
|
|
93
|
+
TypeError: If ``app`` does not expose ``add_exception_handler``.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
from fastapi.responses import JSONResponse
|
|
97
|
+
except ImportError as exc:
|
|
98
|
+
raise ImportError(
|
|
99
|
+
"FastAPI integration requires the optional dependency. "
|
|
100
|
+
"Install async-orm-kit[fastapi]."
|
|
101
|
+
) from exc
|
|
102
|
+
|
|
103
|
+
add_handler = getattr(app, "add_exception_handler", None)
|
|
104
|
+
if not callable(add_handler):
|
|
105
|
+
raise TypeError("app must be a FastAPI-compatible application")
|
|
106
|
+
|
|
107
|
+
async def object_not_found(request: Any, exc: ObjectDoesNotExist) -> JSONResponse:
|
|
108
|
+
return JSONResponse(
|
|
109
|
+
status_code=404,
|
|
110
|
+
content={"detail": "Resource not found"},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def multiple_objects(
|
|
114
|
+
request: Any, exc: MultipleObjectsReturned
|
|
115
|
+
) -> JSONResponse:
|
|
116
|
+
return JSONResponse(
|
|
117
|
+
status_code=409,
|
|
118
|
+
content={"detail": "Multiple resources matched the request"},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def database_failure(request: Any, exc: DatabaseError) -> JSONResponse:
|
|
122
|
+
# Do not return exc.message, exc.cause, SQL, parameters, or credentials.
|
|
123
|
+
return JSONResponse(
|
|
124
|
+
status_code=500,
|
|
125
|
+
content={"detail": "A database operation failed"},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
add_handler(ObjectDoesNotExist, object_not_found)
|
|
129
|
+
add_handler(MultipleObjectsReturned, multiple_objects)
|
|
130
|
+
add_handler(DatabaseError, database_failure)
|
|
131
|
+
return app
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = ["create_session_dependency", "register_exception_handlers"]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Adapter for the separately distributed ``async-storage-kit`` package.
|
|
2
|
+
|
|
3
|
+
The adapter intentionally uses structural typing and does not import
|
|
4
|
+
``async_storage_kit`` at runtime. Applications install the optional ``storage``
|
|
5
|
+
extra and pass a configured backend (or registry proxy) to the adapter.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
ConflictPolicy = Literal["rename", "overwrite", "error"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class AsyncStorage(Protocol):
|
|
19
|
+
"""The stable common subset of async-storage-kit's public contract."""
|
|
20
|
+
|
|
21
|
+
async def save(
|
|
22
|
+
self,
|
|
23
|
+
name: str,
|
|
24
|
+
content: Any,
|
|
25
|
+
max_length: int | None = None,
|
|
26
|
+
*,
|
|
27
|
+
on_conflict: ConflictPolicy = "rename",
|
|
28
|
+
) -> str: ...
|
|
29
|
+
|
|
30
|
+
def iter_chunks(
|
|
31
|
+
self, name: str, chunk_size: int = 256 * 1024
|
|
32
|
+
) -> AsyncIterator[bytes]: ...
|
|
33
|
+
|
|
34
|
+
async def read(self, name: str) -> bytes: ...
|
|
35
|
+
|
|
36
|
+
async def delete(self, name: str, raise_on_missing: bool = False) -> bool: ...
|
|
37
|
+
|
|
38
|
+
async def exists(self, name: str) -> bool: ...
|
|
39
|
+
|
|
40
|
+
async def size(self, name: str) -> int: ...
|
|
41
|
+
|
|
42
|
+
async def get_modified_time(self, name: str) -> Any: ...
|
|
43
|
+
|
|
44
|
+
async def metadata(self, name: str) -> Any: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LazyStorageProxyLike(Protocol):
|
|
48
|
+
"""Structural type for async-storage-kit's lazily resolving proxy."""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def backend(self) -> AsyncStorage: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AsyncStorageKitAdapter:
|
|
55
|
+
"""Adapt an ``async-storage-kit`` backend to ORM ``FileField``.
|
|
56
|
+
|
|
57
|
+
``async-storage-kit`` exposes byte reads and async chunk iteration instead
|
|
58
|
+
of an ``open()`` method. The legacy ``FieldFile.open()`` API is therefore
|
|
59
|
+
implemented with an in-memory ``BytesIO``; use ``FieldFile.iter_chunks()``
|
|
60
|
+
for bounded-memory downloads.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
storage: Configured ``async-storage-kit`` backend or registry proxy.
|
|
64
|
+
on_conflict: Default save collision policy (``rename``, ``overwrite``
|
|
65
|
+
or ``error``). The storage backend performs collision handling.
|
|
66
|
+
|
|
67
|
+
The adapter does not own or close the backend. Configure backend lifetime
|
|
68
|
+
at application scope and inject this adapter into ``FileField(storage=...)``.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
_CONFLICT_POLICIES = frozenset({"rename", "overwrite", "error"})
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
storage: AsyncStorage | LazyStorageProxyLike,
|
|
76
|
+
*,
|
|
77
|
+
on_conflict: ConflictPolicy = "rename",
|
|
78
|
+
) -> None:
|
|
79
|
+
if on_conflict not in self._CONFLICT_POLICIES:
|
|
80
|
+
raise ValueError("on_conflict must be 'rename', 'overwrite', or 'error'.")
|
|
81
|
+
|
|
82
|
+
if storage is None:
|
|
83
|
+
raise TypeError("storage must be a configured async storage backend")
|
|
84
|
+
|
|
85
|
+
# Do not inspect methods here: async-storage-kit's LazyStorageProxy
|
|
86
|
+
# resolves its backend from __getattr__, so eager validation would
|
|
87
|
+
# defeat lazy construction. Required methods are checked when invoked.
|
|
88
|
+
self._storage = storage
|
|
89
|
+
self._on_conflict = on_conflict
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def on_conflict(self) -> ConflictPolicy:
|
|
93
|
+
return self._on_conflict
|
|
94
|
+
|
|
95
|
+
def _required_method(self, name: str) -> Any:
|
|
96
|
+
method = getattr(self._storage, name, None)
|
|
97
|
+
if not callable(method):
|
|
98
|
+
raise TypeError(
|
|
99
|
+
f"Storage backend does not implement required method {name}()"
|
|
100
|
+
)
|
|
101
|
+
return method
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def backend(self) -> AsyncStorage | LazyStorageProxyLike:
|
|
105
|
+
"""Return the injected backend or proxy without resolving or owning it."""
|
|
106
|
+
return self._storage
|
|
107
|
+
|
|
108
|
+
async def save(
|
|
109
|
+
self,
|
|
110
|
+
name: str,
|
|
111
|
+
content: Any,
|
|
112
|
+
*,
|
|
113
|
+
max_length: int | None = None,
|
|
114
|
+
) -> str:
|
|
115
|
+
saved_name = await self._required_method("save")(
|
|
116
|
+
name,
|
|
117
|
+
content,
|
|
118
|
+
max_length=max_length,
|
|
119
|
+
on_conflict=self.on_conflict,
|
|
120
|
+
)
|
|
121
|
+
if not isinstance(saved_name, str) or not saved_name:
|
|
122
|
+
raise TypeError("Storage backend save() must return a non-empty name.")
|
|
123
|
+
return saved_name
|
|
124
|
+
|
|
125
|
+
async def open(self, name: str, mode: str = "rb") -> io.BytesIO:
|
|
126
|
+
"""Return a buffered binary file handle for the legacy FileField API.
|
|
127
|
+
|
|
128
|
+
This operation materializes the entire object in memory. Prefer
|
|
129
|
+
``iter_chunks()`` for large objects.
|
|
130
|
+
"""
|
|
131
|
+
if mode != "rb":
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"AsyncStorageKitAdapter.open() supports binary read mode 'rb' only."
|
|
134
|
+
)
|
|
135
|
+
return io.BytesIO(await self._required_method("read")(name))
|
|
136
|
+
|
|
137
|
+
async def read(self, name: str) -> bytes:
|
|
138
|
+
return await self._required_method("read")(name)
|
|
139
|
+
|
|
140
|
+
def iter_chunks(
|
|
141
|
+
self, name: str, chunk_size: int = 256 * 1024
|
|
142
|
+
) -> AsyncIterator[bytes]:
|
|
143
|
+
if isinstance(chunk_size, bool) or not isinstance(chunk_size, int):
|
|
144
|
+
raise TypeError("chunk_size must be an integer")
|
|
145
|
+
if chunk_size < 1:
|
|
146
|
+
raise ValueError("chunk_size must be positive")
|
|
147
|
+
return self._required_method("iter_chunks")(name, chunk_size=chunk_size)
|
|
148
|
+
|
|
149
|
+
async def delete(self, name: str) -> None:
|
|
150
|
+
await self._required_method("delete")(name)
|
|
151
|
+
|
|
152
|
+
async def exists(self, name: str) -> bool:
|
|
153
|
+
return await self._required_method("exists")(name)
|
|
154
|
+
|
|
155
|
+
async def size(self, name: str) -> int:
|
|
156
|
+
return await self._required_method("size")(name)
|
|
157
|
+
|
|
158
|
+
def generate_filename(self, name: str) -> str:
|
|
159
|
+
"""Use the backend's public filename helper when it provides one.
|
|
160
|
+
|
|
161
|
+
``generate_filename`` is a convenience on async-storage-kit's concrete
|
|
162
|
+
``StorageBackend`` but is intentionally not required by its structural
|
|
163
|
+
``AsyncStorage`` protocol. Custom protocol implementations may validate
|
|
164
|
+
names in ``save()`` and therefore need no extra helper.
|
|
165
|
+
"""
|
|
166
|
+
generator = getattr(self._storage, "generate_filename", None)
|
|
167
|
+
return generator(name) if callable(generator) else name
|
|
168
|
+
|
|
169
|
+
def path(self, name: str) -> str:
|
|
170
|
+
method = getattr(self._storage, "path", None)
|
|
171
|
+
if not callable(method):
|
|
172
|
+
raise NotImplementedError(
|
|
173
|
+
"The configured storage has no local path capability"
|
|
174
|
+
)
|
|
175
|
+
return method(name)
|
|
176
|
+
|
|
177
|
+
def url(self, name: str) -> str:
|
|
178
|
+
method = getattr(self._storage, "url", None)
|
|
179
|
+
if not callable(method):
|
|
180
|
+
raise NotImplementedError(
|
|
181
|
+
"The configured storage has no public URL capability"
|
|
182
|
+
)
|
|
183
|
+
return method(name)
|
|
184
|
+
|
|
185
|
+
async def get_presigned_url(self, name: str, expire: int = 3600) -> str:
|
|
186
|
+
method = getattr(self._storage, "get_presigned_url", None)
|
|
187
|
+
if not callable(method):
|
|
188
|
+
raise NotImplementedError(
|
|
189
|
+
"The configured storage has no signed URL capability"
|
|
190
|
+
)
|
|
191
|
+
return await method(name, expire=expire)
|
|
192
|
+
|
|
193
|
+
async def get_modified_time(self, name: str) -> Any:
|
|
194
|
+
return await self._required_method("get_modified_time")(name)
|
|
195
|
+
|
|
196
|
+
async def get_accessed_time(self, name: str) -> Any:
|
|
197
|
+
method = getattr(self._storage, "get_accessed_time", None)
|
|
198
|
+
if not callable(method):
|
|
199
|
+
raise NotImplementedError(
|
|
200
|
+
"The configured storage does not track access time"
|
|
201
|
+
)
|
|
202
|
+
return await method(name)
|
|
203
|
+
|
|
204
|
+
async def get_created_time(self, name: str) -> Any:
|
|
205
|
+
method = getattr(self._storage, "get_created_time", None)
|
|
206
|
+
if not callable(method):
|
|
207
|
+
raise NotImplementedError(
|
|
208
|
+
"The configured storage does not track creation time"
|
|
209
|
+
)
|
|
210
|
+
return await method(name)
|
|
211
|
+
|
|
212
|
+
async def metadata(self, name: str) -> Any:
|
|
213
|
+
return await self._required_method("metadata")(name)
|