fastapi-async-sqlalchemy 0.7.2a0__tar.gz → 0.8.0b1__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.
Files changed (43) hide show
  1. fastapi_async_sqlalchemy-0.8.0b1/PKG-INFO +386 -0
  2. fastapi_async_sqlalchemy-0.8.0b1/README.md +343 -0
  3. fastapi_async_sqlalchemy-0.8.0b1/fastapi_async_sqlalchemy/__init__.py +32 -0
  4. fastapi_async_sqlalchemy-0.8.0b1/fastapi_async_sqlalchemy/_types.py +63 -0
  5. fastapi_async_sqlalchemy-0.8.0b1/fastapi_async_sqlalchemy/middleware.py +787 -0
  6. fastapi_async_sqlalchemy-0.8.0b1/fastapi_async_sqlalchemy.egg-info/PKG-INFO +386 -0
  7. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/fastapi_async_sqlalchemy.egg-info/SOURCES.txt +5 -0
  8. fastapi_async_sqlalchemy-0.8.0b1/fastapi_async_sqlalchemy.egg-info/requires.txt +2 -0
  9. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/pyproject.toml +1 -2
  10. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/setup.py +5 -5
  11. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_additional_coverage.py +22 -11
  12. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_backward_compat_gather.py +29 -57
  13. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_concurrent_queries.py +7 -7
  14. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_coverage_boost.py +9 -19
  15. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_coverage_improvements.py +18 -18
  16. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_custom_engine_branch.py +40 -23
  17. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_edge_cases_coverage.py +29 -22
  18. fastapi_async_sqlalchemy-0.8.0b1/tests/test_full_coverage.py +227 -0
  19. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_import_fallback_simulation.py +17 -9
  20. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_import_fallbacks.py +0 -24
  21. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_maximum_coverage.py +30 -31
  22. fastapi_async_sqlalchemy-0.8.0b1/tests/test_multi_session_fixes.py +180 -0
  23. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_pool_throttling.py +99 -2
  24. fastapi_async_sqlalchemy-0.8.0b1/tests/test_resource_lifecycle.py +675 -0
  25. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_session.py +20 -14
  26. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_single_session_no_gather.py +16 -11
  27. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_sqlmodel.py +16 -16
  28. fastapi_async_sqlalchemy-0.8.0b1/tests/test_streaming_and_waiter_shutdown.py +90 -0
  29. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_type_hints_compatibility.py +33 -3
  30. fastapi_async_sqlalchemy-0.7.2a0/PKG-INFO +0 -212
  31. fastapi_async_sqlalchemy-0.7.2a0/README.md +0 -169
  32. fastapi_async_sqlalchemy-0.7.2a0/fastapi_async_sqlalchemy/__init__.py +0 -22
  33. fastapi_async_sqlalchemy-0.7.2a0/fastapi_async_sqlalchemy/middleware.py +0 -484
  34. fastapi_async_sqlalchemy-0.7.2a0/fastapi_async_sqlalchemy.egg-info/PKG-INFO +0 -212
  35. fastapi_async_sqlalchemy-0.7.2a0/fastapi_async_sqlalchemy.egg-info/requires.txt +0 -2
  36. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/LICENSE +0 -0
  37. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/fastapi_async_sqlalchemy/exceptions.py +0 -0
  38. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/fastapi_async_sqlalchemy/py.typed +0 -0
  39. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/fastapi_async_sqlalchemy.egg-info/dependency_links.txt +0 -0
  40. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/fastapi_async_sqlalchemy.egg-info/not-zip-safe +0 -0
  41. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/fastapi_async_sqlalchemy.egg-info/top_level.txt +0 -0
  42. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/setup.cfg +0 -0
  43. {fastapi_async_sqlalchemy-0.7.2a0 → fastapi_async_sqlalchemy-0.8.0b1}/tests/test_import_without_sqlmodel.py +0 -0
@@ -0,0 +1,386 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-async-sqlalchemy
3
+ Version: 0.8.0b1
4
+ Summary: SQLAlchemy middleware for FastAPI
5
+ Home-page: https://github.com/h0rn3t/fastapi-async-sqlalchemy.git
6
+ Author: Eugene Shershen
7
+ Author-email: h0rn3t.null@gmail.com
8
+ License: MIT
9
+ Project-URL: Code, https://github.com/h0rn3t/fastapi-async-sqlalchemy
10
+ Project-URL: Issue tracker, https://github.com/h0rn3t/fastapi-async-sqlalchemy/issues
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: 3.15
21
+ Classifier: Programming Language :: Python :: 3 :: Only
22
+ Classifier: Programming Language :: Python :: Implementation :: CPython
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Requires-Python: >=3.12
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: starlette>=0.40
30
+ Requires-Dist: SQLAlchemy>=2.0
31
+ Dynamic: author
32
+ Dynamic: author-email
33
+ Dynamic: classifier
34
+ Dynamic: description
35
+ Dynamic: description-content-type
36
+ Dynamic: home-page
37
+ Dynamic: license
38
+ Dynamic: license-file
39
+ Dynamic: project-url
40
+ Dynamic: requires-dist
41
+ Dynamic: requires-python
42
+ Dynamic: summary
43
+
44
+ # SQLAlchemy FastAPI middleware
45
+
46
+ [![ci](https://img.shields.io/badge/Support-Ukraine-FFD500?style=flat&labelColor=005BBB)](https://img.shields.io/badge/Support-Ukraine-FFD500?style=flat&labelColor=005BBB)
47
+ [![ci](https://github.com/h0rn3t/fastapi-async-sqlalchemy/workflows/ci/badge.svg)](https://github.com/h0rn3t/fastapi-async-sqlalchemy/workflows/ci/badge.svg)
48
+ [![codecov](https://codecov.io/gh/h0rn3t/fastapi-async-sqlalchemy/branch/main/graph/badge.svg?token=F4NJ34WKPY)](https://codecov.io/gh/h0rn3t/fastapi-async-sqlalchemy)
49
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
50
+ [![pip](https://img.shields.io/pypi/v/fastapi_async_sqlalchemy?color=blue)](https://pypi.org/project/fastapi-async-sqlalchemy/)
51
+ [![Downloads](https://static.pepy.tech/badge/fastapi-async-sqlalchemy)](https://pepy.tech/project/fastapi-async-sqlalchemy)
52
+ [![Updates](https://pyup.io/repos/github/h0rn3t/fastapi-async-sqlalchemy/shield.svg)](https://pyup.io/repos/github/h0rn3t/fastapi-async-sqlalchemy/)
53
+
54
+ ### Description
55
+
56
+ Provides SQLAlchemy middleware for FastAPI using AsyncSession and async engine.
57
+
58
+ ### Install
59
+
60
+ ```bash
61
+ pip install fastapi-async-sqlalchemy
62
+ ```
63
+
64
+
65
+ It also works with ```sqlmodel```
66
+
67
+
68
+ ### Examples
69
+
70
+ Note that the session object provided by ``db.session`` is based on the Python3.7+ ``ContextVar``. This means that
71
+ each session is linked to the individual request context in which it was created.
72
+
73
+ ```python
74
+
75
+ from fastapi import FastAPI
76
+ from fastapi_async_sqlalchemy import SQLAlchemyMiddleware
77
+ from fastapi_async_sqlalchemy import db # provide access to a database session
78
+ from sqlalchemy import column
79
+ from sqlalchemy import table
80
+
81
+ app = FastAPI()
82
+ app.add_middleware(
83
+ SQLAlchemyMiddleware,
84
+ db_url="postgresql+asyncpg://user:user@192.168.88.200:5432/primary_db",
85
+ engine_args={ # engine arguments example
86
+ "echo": True, # print all SQL statements
87
+ "pool_pre_ping": True, # feature will normally emit SQL equivalent to “SELECT 1” each time a connection is checked out from the pool
88
+ "pool_size": 5, # number of connections to keep open at a time
89
+ "max_overflow": 10, # number of connections to allow to be opened above pool_size
90
+ },
91
+ )
92
+ # Engines created from ``db_url`` are owned by the middleware and are disposed
93
+ # during the application shutdown lifespan. Tests that need shutdown behavior
94
+ # should run the app lifespan, for example with ``with TestClient(app)``.
95
+ # once the middleware is applied, any route can then access the database session
96
+ # from the global ``db``
97
+
98
+ foo = table("ms_files", column("id"))
99
+
100
+ # Usage inside of a route
101
+ @app.get("/")
102
+ async def get_files():
103
+ result = await db.session.execute(foo.select())
104
+ return result.fetchall()
105
+
106
+ async def get_db_fetch():
107
+ # It uses the same ``db`` object and use it as a context manager:
108
+ async with db():
109
+ result = await db.session.execute(foo.select())
110
+ return result.fetchall()
111
+
112
+ # Usage inside of a route using a db context
113
+ @app.get("/db_context")
114
+ async def db_context():
115
+ return await get_db_fetch()
116
+
117
+ # Usage outside of a route using a db context
118
+ @app.on_event("startup")
119
+ async def on_startup():
120
+ # We are outside of a request context, therefore we cannot rely on ``SQLAlchemyMiddleware``
121
+ # to create a database session for us.
122
+ result = await get_db_fetch()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ import uvicorn
127
+ uvicorn.run(app, host="0.0.0.0", port=8002)
128
+
129
+ ```
130
+
131
+ #### Engine ownership
132
+
133
+ When the middleware receives ``db_url``, it creates and owns the async engine.
134
+ The engine is kept for the application lifetime and disposed when the ASGI
135
+ lifespan shutdown completes. It is not disposed per request. Disposal also
136
+ runs when the lifespan ends with a failure (``lifespan.shutdown.failed`` or
137
+ ``lifespan.startup.failed``), so a raising user shutdown handler does not leak
138
+ the connection pool.
139
+
140
+ Engine disposal happens before the lifespan acknowledgement is forwarded to
141
+ the ASGI server, so a stuck pool drain will block the server's graceful
142
+ shutdown ack. Configure your ASGI server's graceful shutdown timeout (for
143
+ example uvicorn's ``--timeout-graceful-shutdown``) so it accommodates the
144
+ worst-case time required to close active connections.
145
+
146
+ When the middleware receives ``custom_engine``, the caller owns that engine. The
147
+ middleware will use it but will not dispose it during application shutdown:
148
+
149
+ ```python
150
+ from sqlalchemy.ext.asyncio import create_async_engine
151
+
152
+ engine = create_async_engine("postgresql+asyncpg://user:pass@host/db")
153
+ app.add_middleware(SQLAlchemyMiddleware, custom_engine=engine)
154
+
155
+ # Later, in caller-managed shutdown code or test cleanup:
156
+ await engine.dispose()
157
+ ```
158
+
159
+ #### Manual disposal outside ASGI lifespan
160
+
161
+ When ``SQLAlchemyMiddleware(db_url=...)`` is constructed outside an ASGI
162
+ application lifespan — for example in a script, an ad-hoc test harness, or
163
+ when embedding the middleware in a non-ASGI runtime — there is no
164
+ ``lifespan.shutdown`` event to trigger engine disposal. In that case call
165
+ ``await middleware.dispose()`` explicitly so the middleware-owned engine is
166
+ released:
167
+
168
+ ```python
169
+ middleware = SQLAlchemyMiddleware(app, db_url="postgresql+asyncpg://...")
170
+ try:
171
+ ... # use db.session
172
+ finally:
173
+ await middleware.dispose()
174
+ ```
175
+
176
+ ``dispose()`` is idempotent on success and is safe to retry if it raises:
177
+ the proxy session bindings are cleared deterministically so a subsequent
178
+ call actually re-attempts the underlying ``engine.dispose()``. The same
179
+ guidance applies to each pair created by
180
+ ``create_middleware_and_session_proxy()``.
181
+
182
+ #### Request transactions and streaming responses
183
+
184
+ When ``SQLAlchemyMiddleware(..., commit_on_exit=True)`` manages a normal
185
+ non-streaming HTTP request, the request session is committed before
186
+ ``http.response.start`` is forwarded to the ASGI server. If commit, rollback,
187
+ or close fails, the failure happens before a successful response is reported to
188
+ the client.
189
+
190
+ Streaming response body generation has a different lifetime from a normal
191
+ request transaction. Do not rely on the middleware-managed request session to
192
+ stay open while a ``StreamingResponse``/``FileResponse`` yields chunks. Open an
193
+ explicit session inside the generator so the body owns the database lifetime:
194
+
195
+ ```python
196
+ from fastapi.responses import StreamingResponse
197
+
198
+ @app.get("/export")
199
+ async def export():
200
+ async def rows():
201
+ async with db():
202
+ result = await db.session.stream(foo.select())
203
+ async for row in result:
204
+ yield f"{row.id}\n".encode()
205
+ return StreamingResponse(rows(), media_type="text/plain")
206
+ ```
207
+
208
+ Implicit ``commit_on_exit=True`` is not a safe way to report streaming write
209
+ success: the response may have already started before an unbounded body is
210
+ finished. If a streaming route needs database writes, either complete and
211
+ commit the write in a separate explicit ``async with db(commit_on_exit=True)``
212
+ block before creating the streaming response, or make the streaming generator
213
+ use an explicit ``async with db(commit_on_exit=True)`` block and design the API
214
+ so clients do not treat early chunks as write success.
215
+
216
+ For applications that previously used ``db.session`` directly inside streaming
217
+ generators, move that code into an explicit generator-owned context as shown
218
+ above. This keeps database access available for the whole body while making it
219
+ clear that the session lifetime belongs to the stream, not the original request
220
+ transaction.
221
+
222
+ #### Type hints for `db`
223
+
224
+ Use `DBSessionMeta` (or its alias `DBSessionType`) when you need to annotate
225
+ a function or attribute that holds the `db` proxy:
226
+
227
+ ```python
228
+ from fastapi_async_sqlalchemy import DBSessionMeta, db
229
+
230
+ def get_db() -> DBSessionMeta:
231
+ return db
232
+ ```
233
+
234
+ At runtime `DBSessionMeta` is the metaclass of `db`, so `isinstance(db,
235
+ DBSessionMeta)` and `type(db) is DBSessionMeta` both work. For static type
236
+ checkers (mypy, pyright) and IDE autocompletion `DBSessionMeta` resolves to
237
+ a structural `Protocol` describing the public API (`session`, `connection`,
238
+ `gather`, and the `db(...)` call).
239
+
240
+ #### SQLAlchemy events (`before_insert`, `after_insert`, ...)
241
+
242
+ SQLAlchemy's event system is independent of the session/engine — register
243
+ listeners on your mapped classes (or on `Mapper`/`Session`) with
244
+ `sqlalchemy.event.listens_for` exactly as you would with a synchronous
245
+ SQLAlchemy setup. The middleware does not change how events fire.
246
+
247
+ ```python
248
+ from datetime import datetime
249
+ from sqlalchemy import Column, DateTime, Integer, String, event
250
+ from sqlalchemy.orm import DeclarativeBase
251
+
252
+
253
+ class Base(DeclarativeBase):
254
+ pass
255
+
256
+
257
+ class User(Base):
258
+ __tablename__ = "users"
259
+ id = Column(Integer, primary_key=True)
260
+ username = Column(String(50), unique=True, nullable=False)
261
+ created_at = Column(DateTime, default=datetime.utcnow)
262
+ updated_at = Column(DateTime, default=datetime.utcnow)
263
+
264
+
265
+ @event.listens_for(User, "before_insert")
266
+ def normalize(mapper, connection, target):
267
+ target.username = target.username.lower().strip()
268
+
269
+
270
+ @event.listens_for(User, "before_update")
271
+ def touch_updated_at(mapper, connection, target):
272
+ target.updated_at = datetime.utcnow()
273
+
274
+
275
+ @event.listens_for(User, "after_insert")
276
+ def log_insert(mapper, connection, target):
277
+ print(f"user created: id={target.id}")
278
+ ```
279
+
280
+ Mapper-level events (`before_insert`, `after_insert`, `before_update`,
281
+ `after_update`, `before_delete`, `after_delete`) receive a synchronous
282
+ `connection` argument — do **not** `await` inside them and do **not** call
283
+ async ORM APIs there. If you need async work after a write, do it after
284
+ `await db.session.commit()` returns, or use `Session`-level events such as
285
+ `after_flush` / `after_commit` and schedule async work from there.
286
+
287
+ A complete runnable example with validation, timestamps, logging, and
288
+ soft-delete hooks lives at [examples/events_example.py](examples/events_example.py).
289
+
290
+ #### Usage of multiple databases
291
+
292
+ databases.py
293
+
294
+ ```python
295
+ from fastapi import FastAPI
296
+ from fastapi_async_sqlalchemy import create_middleware_and_session_proxy
297
+
298
+ FirstSQLAlchemyMiddleware, first_db = create_middleware_and_session_proxy()
299
+ SecondSQLAlchemyMiddleware, second_db = create_middleware_and_session_proxy()
300
+ ```
301
+
302
+ Use a separate middleware/session proxy pair for each independent app or
303
+ database. Reusing the same proxy with a different live engine is rejected so
304
+ requests cannot silently switch to another database binding.
305
+
306
+ main.py
307
+
308
+ ```python
309
+ from fastapi import FastAPI
310
+
311
+ from databases import FirstSQLAlchemyMiddleware, SecondSQLAlchemyMiddleware
312
+ from routes import router
313
+
314
+ app = FastAPI()
315
+
316
+ app.include_router(router)
317
+
318
+ app.add_middleware(
319
+ FirstSQLAlchemyMiddleware,
320
+ db_url="postgresql+asyncpg://user:user@192.168.88.200:5432/primary_db",
321
+ engine_args={
322
+ "pool_size": 5,
323
+ "max_overflow": 10,
324
+ },
325
+ )
326
+ app.add_middleware(
327
+ SecondSQLAlchemyMiddleware,
328
+ db_url="mysql+aiomysql://user:user@192.168.88.200:5432/primary_db",
329
+ engine_args={
330
+ "pool_size": 5,
331
+ "max_overflow": 10,
332
+ },
333
+ )
334
+ ```
335
+
336
+ routes.py
337
+
338
+ ```python
339
+ import asyncio
340
+
341
+ from fastapi import APIRouter
342
+ from sqlalchemy import column, table, text
343
+
344
+ from databases import first_db, second_db
345
+
346
+ router = APIRouter()
347
+
348
+ foo = table("ms_files", column("id"))
349
+
350
+ @router.get("/first-db-files")
351
+ async def get_files_from_first_db():
352
+ result = await first_db.session.execute(foo.select())
353
+ return result.fetchall()
354
+
355
+
356
+ @router.get("/second-db-files")
357
+ async def get_files_from_second_db():
358
+ result = await second_db.session.execute(foo.select())
359
+ return result.fetchall()
360
+
361
+
362
+ @router.get("/concurrent-queries")
363
+ async def parallel_select():
364
+ async with first_db(multi_sessions=True, max_concurrent=10):
365
+ async def execute_query(query):
366
+ async with first_db.connection() as session:
367
+ return await session.execute(text(query))
368
+
369
+ tasks = [
370
+ asyncio.create_task(execute_query("SELECT 1")),
371
+ asyncio.create_task(execute_query("SELECT 2")),
372
+ asyncio.create_task(execute_query("SELECT 3")),
373
+ asyncio.create_task(execute_query("SELECT 4")),
374
+ asyncio.create_task(execute_query("SELECT 5")),
375
+ asyncio.create_task(execute_query("SELECT 6")),
376
+ ]
377
+
378
+ await asyncio.gather(*tasks)
379
+ ```
380
+
381
+ Child tasks that use database sessions must finish before the owning
382
+ ``async with db(multi_sessions=True)`` block exits. When ``max_concurrent`` is
383
+ set, child tasks should use ``db.connection()`` or pass coroutine objects to
384
+ ``db.gather()`` so the middleware can own both the session lifetime and the
385
+ semaphore slot. Already-created ``Task`` or ``Future`` objects are rejected by
386
+ throttled ``db.gather()`` because they may have started outside the semaphore.