fastapi-sqla 3.1.2__tar.gz → 3.4.8__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.

Potentially problematic release.


This version of fastapi-sqla might be problematic. Click here for more details.

@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-sqla
3
- Version: 3.1.2
3
+ Version: 3.4.8
4
4
  Summary: SQLAlchemy extension for FastAPI with support for pagination, asyncio, SQLModel, and pytest, ready for production.
5
5
  Home-page: https://github.com/dialoguemd/fastapi-sqla
6
6
  License: MIT
7
7
  Keywords: FastAPI,SQLAlchemy,asyncio,pytest,alembic
8
8
  Author: Hadrien David
9
9
  Author-email: hadrien.david@dialogue.co
10
- Requires-Python: >=3.9,<4.0
10
+ Requires-Python: >=3.9,<3.14
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Environment :: Web Environment
13
13
  Classifier: Framework :: AsyncIO
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3
22
22
  Classifier: Programming Language :: Python :: 3.9
23
23
  Classifier: Programming Language :: Python :: 3.10
24
24
  Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
25
26
  Classifier: Programming Language :: Python :: 3 :: Only
26
27
  Classifier: Programming Language :: SQL
27
28
  Classifier: Topic :: Internet
@@ -34,30 +35,19 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
34
35
  Classifier: Typing :: Typed
35
36
  Provides-Extra: asyncpg
36
37
  Provides-Extra: aws-rds-iam
38
+ Provides-Extra: psycopg2
39
+ Provides-Extra: pytest-plugin
37
40
  Provides-Extra: sqlmodel
38
- Provides-Extra: tests
39
- Requires-Dist: Faker (>=14.2.0,<15.0.0) ; extra == "tests"
40
- Requires-Dist: alembic (>=1.4.3,<2.0.0) ; extra == "tests"
41
- Requires-Dist: asgi_lifespan (>=1.0.1,<2.0.0) ; extra == "tests"
42
- Requires-Dist: asyncpg (>=0.28.0,<0.29.0) ; extra == "asyncpg"
43
- Requires-Dist: black (>=22.8.0,<23.0.0) ; extra == "tests"
44
- Requires-Dist: boto3 (>=1.24.74,<2.0.0) ; extra == "aws-rds-iam"
45
- Requires-Dist: fastapi (>=0.95.1)
46
- Requires-Dist: greenlet (>=1.1.3,<2.0.0) ; extra == "tests"
47
- Requires-Dist: httpx (>=0.23.0,<0.24.0) ; extra == "tests"
48
- Requires-Dist: isort (>=5.5.3,<6.0.0) ; extra == "tests"
49
- Requires-Dist: mypy[tests] (>=1.0.0,<2.0.0) ; extra == "tests"
50
- Requires-Dist: pdbpp (>=0.10.2,<0.11.0) ; extra == "tests"
51
- Requires-Dist: psycopg2 (>=2.8.6,<3.0.0) ; extra == "tests"
52
- Requires-Dist: pydantic (>=1)
53
- Requires-Dist: pylama (>=8.4.1,<9.0.0) ; extra == "tests"
54
- Requires-Dist: pytest (>=7.2.1,<8.0.0) ; extra == "tests"
55
- Requires-Dist: pytest-asyncio (>=0.19.0,<0.20.0) ; extra == "tests"
56
- Requires-Dist: pytest-cov (>=2.10.1,<3.0.0) ; extra == "tests"
57
- Requires-Dist: sqlalchemy (>=1.3)
58
- Requires-Dist: sqlmodel (>=0.0.14,<0.0.15) ; extra == "sqlmodel"
59
- Requires-Dist: structlog (>=20)
60
- Requires-Dist: tox (>=3.26.0,<4.0.0) ; extra == "tests"
41
+ Requires-Dist: alembic (>=1.4.3,<2) ; extra == "pytest-plugin"
42
+ Requires-Dist: asyncpg (>=0.28.0,<0.31.0) ; extra == "asyncpg"
43
+ Requires-Dist: boto3 (>=1.24.74,<2) ; extra == "aws-rds-iam"
44
+ Requires-Dist: deprecated (>=1.2,<2)
45
+ Requires-Dist: fastapi (>=0.95.1,<0.116)
46
+ Requires-Dist: psycopg2 (>=2.8.6,<3) ; extra == "psycopg2"
47
+ Requires-Dist: pydantic (>=1,<3)
48
+ Requires-Dist: sqlalchemy (>=1.3,<3)
49
+ Requires-Dist: sqlmodel (>=0.0.14,<0.0.23) ; extra == "sqlmodel"
50
+ Requires-Dist: structlog (>=20,<26)
61
51
  Project-URL: Repository, https://github.com/dialoguemd/fastapi-sqla
62
52
  Description-Content-Type: text/markdown
63
53
 
@@ -83,6 +73,8 @@ Using [pip](https://pip.pypa.io/):
83
73
  pip install fastapi-sqla
84
74
  ```
85
75
 
76
+ Note that you need a [SQLAlchemy compatible engine](https://docs.sqlalchemy.org/en/20/core/engines.html) adapter. We test with `psycopg2` which you can install using the `psycopg2` extra.
77
+
86
78
  # Quick Example
87
79
 
88
80
  Assuming it runs against a DB with a table `user` with 3 columns, `id`, `name` and
@@ -90,15 +82,22 @@ unique `email`:
90
82
 
91
83
  ```python
92
84
  # main.py
85
+ from contextlib import asynccontextmanager
93
86
  from fastapi import FastAPI, HTTPException
94
- from fastapi_sqla import Base, Item, Page, Paginate, Session, setup
87
+ from fastapi_sqla import Base, Item, Page, Paginate, Session, setup_middlewares, startup
95
88
  from pydantic import BaseModel, EmailStr
96
89
  from sqlalchemy import select
97
90
  from sqlalchemy.exc import IntegrityError
98
91
 
99
- app = FastAPI()
100
92
 
101
- setup(app)
93
+ @asynccontextmanager
94
+ async def lifespan(app: FastAPI):
95
+ await startup()
96
+ yield
97
+
98
+
99
+ app = FastAPI(lifespan=lifespan)
100
+ setup_middlewares(app)
102
101
 
103
102
 
104
103
  class User(Base):
@@ -203,7 +202,23 @@ And define the environment variable `sqlalchemy_url` with `postgres+asyncpg` sch
203
202
  export sqlalchemy_url=postgresql+asyncpg://postgres@localhost
204
203
  ```
205
204
 
206
- ## Setup the app:
205
+ ## Setup the app AsyncContextManager (recommended):
206
+
207
+ ```python
208
+ import fastapi_sqla
209
+ from fastapi import FastAPI
210
+
211
+ @asynccontextmanager
212
+ async def lifespan(app: FastAPI):
213
+ await fastapi_sqla.startup()
214
+ yield
215
+
216
+
217
+ app = FastAPI(lifespan=lifespan)
218
+ fastapi_sqla.setup_middlewares(app)
219
+ ```
220
+
221
+ ## Setup the app using startup/shutdown events (deprecated):
207
222
 
208
223
  ```python
209
224
  import fastapi_sqla
@@ -640,7 +655,7 @@ If your project uses [SQLModel], then `Session` dependency is an SQLModel sessio
640
655
  # Pytest fixtures
641
656
 
642
657
  This library provides a set of utility fixtures, through its PyTest plugin, which is
643
- automatically installed with the library.
658
+ automatically installed with the library. Using the plugin requires the `pytest_plugin` extra.
644
659
 
645
660
  By default, no records are actually written to the database when running tests.
646
661
  There currently is no way to change this behaviour.
@@ -784,7 +799,7 @@ It returns the path of `alembic.ini` configuration file. By default, it returns
784
799
  ## Setup
785
800
 
786
801
  ```bash
787
- $ poetry install --extras tests --extras asyncpg --extras aws_rds_iam
802
+ $ poetry install --all-extras
788
803
  ```
789
804
 
790
805
  ## Running tests
@@ -20,6 +20,8 @@ Using [pip](https://pip.pypa.io/):
20
20
  pip install fastapi-sqla
21
21
  ```
22
22
 
23
+ Note that you need a [SQLAlchemy compatible engine](https://docs.sqlalchemy.org/en/20/core/engines.html) adapter. We test with `psycopg2` which you can install using the `psycopg2` extra.
24
+
23
25
  # Quick Example
24
26
 
25
27
  Assuming it runs against a DB with a table `user` with 3 columns, `id`, `name` and
@@ -27,15 +29,22 @@ unique `email`:
27
29
 
28
30
  ```python
29
31
  # main.py
32
+ from contextlib import asynccontextmanager
30
33
  from fastapi import FastAPI, HTTPException
31
- from fastapi_sqla import Base, Item, Page, Paginate, Session, setup
34
+ from fastapi_sqla import Base, Item, Page, Paginate, Session, setup_middlewares, startup
32
35
  from pydantic import BaseModel, EmailStr
33
36
  from sqlalchemy import select
34
37
  from sqlalchemy.exc import IntegrityError
35
38
 
36
- app = FastAPI()
37
39
 
38
- setup(app)
40
+ @asynccontextmanager
41
+ async def lifespan(app: FastAPI):
42
+ await startup()
43
+ yield
44
+
45
+
46
+ app = FastAPI(lifespan=lifespan)
47
+ setup_middlewares(app)
39
48
 
40
49
 
41
50
  class User(Base):
@@ -140,7 +149,23 @@ And define the environment variable `sqlalchemy_url` with `postgres+asyncpg` sch
140
149
  export sqlalchemy_url=postgresql+asyncpg://postgres@localhost
141
150
  ```
142
151
 
143
- ## Setup the app:
152
+ ## Setup the app AsyncContextManager (recommended):
153
+
154
+ ```python
155
+ import fastapi_sqla
156
+ from fastapi import FastAPI
157
+
158
+ @asynccontextmanager
159
+ async def lifespan(app: FastAPI):
160
+ await fastapi_sqla.startup()
161
+ yield
162
+
163
+
164
+ app = FastAPI(lifespan=lifespan)
165
+ fastapi_sqla.setup_middlewares(app)
166
+ ```
167
+
168
+ ## Setup the app using startup/shutdown events (deprecated):
144
169
 
145
170
  ```python
146
171
  import fastapi_sqla
@@ -577,7 +602,7 @@ If your project uses [SQLModel], then `Session` dependency is an SQLModel sessio
577
602
  # Pytest fixtures
578
603
 
579
604
  This library provides a set of utility fixtures, through its PyTest plugin, which is
580
- automatically installed with the library.
605
+ automatically installed with the library. Using the plugin requires the `pytest_plugin` extra.
581
606
 
582
607
  By default, no records are actually written to the database when running tests.
583
608
  There currently is no way to change this behaviour.
@@ -721,7 +746,7 @@ It returns the path of `alembic.ini` configuration file. By default, it returns
721
746
  ## Setup
722
747
 
723
748
  ```bash
724
- $ poetry install --extras tests --extras asyncpg --extras aws_rds_iam
749
+ $ poetry install --all-extras
725
750
  ```
726
751
 
727
752
  ## Running tests
@@ -1,4 +1,4 @@
1
- from fastapi_sqla.base import setup
1
+ from fastapi_sqla.base import setup, setup_middlewares, startup
2
2
  from fastapi_sqla.models import Collection, Item, Page
3
3
  from fastapi_sqla.pagination import Paginate, PaginateSignature, Pagination
4
4
  from fastapi_sqla.sqla import (
@@ -22,6 +22,8 @@ __all__ = [
22
22
  "SqlaSession",
23
23
  "open_session",
24
24
  "setup",
25
+ "setup_middlewares",
26
+ "startup",
25
27
  ]
26
28
 
27
29
 
@@ -28,7 +28,6 @@ def db_host():
28
28
  When CI key is set in environment variables, it uses `postgres` as host name else,
29
29
  host used is `localhost`
30
30
  """
31
-
32
31
  return "postgres" if "CI" in os.environ else "localhost"
33
32
 
34
33
 
@@ -38,7 +37,6 @@ def db_user():
38
37
 
39
38
  postgres
40
39
  """
41
-
42
40
  return "postgres"
43
41
 
44
42
 
@@ -48,7 +46,6 @@ def db_url(db_host, db_user):
48
46
 
49
47
  db url example postgresql://{db_user}@{db_host}/postgres
50
48
  """
51
-
52
49
  return f"postgresql://{db_user}@{db_host}/postgres"
53
50
 
54
51
 
@@ -107,7 +104,6 @@ def sqla_reflection(sqla_modules, sqla_connection):
107
104
  @fixture
108
105
  def patch_engine_from_config(request, sqla_connection):
109
106
  """So that all DB operations are never written to db for real."""
110
-
111
107
  if "dont_patch_engines" in request.keywords:
112
108
  yield
113
109
  else:
@@ -153,7 +149,7 @@ def async_sqlalchemy_url(db_url):
153
149
  return format_async_async_sqlalchemy_url(db_url)
154
150
 
155
151
 
156
- if asyncio_support: # noqa: C901
152
+ if asyncio_support:
157
153
 
158
154
  @fixture
159
155
  def async_engine(async_sqlalchemy_url):
@@ -167,7 +163,6 @@ if asyncio_support: # noqa: C901
167
163
  @fixture
168
164
  async def patch_new_engine(request, async_sqla_connection):
169
165
  """So that all async DB operations are never written to db for real."""
170
-
171
166
  if "dont_patch_engines" in request.keywords:
172
167
  yield
173
168
  else:
@@ -1,6 +1,6 @@
1
1
  import math
2
- from collections.abc import Awaitable, Callable
3
- from typing import Annotated, Iterator, Optional, Union, cast
2
+ from collections.abc import Awaitable, Callable, Iterator
3
+ from typing import Annotated, Optional, Union, cast
4
4
 
5
5
  from fastapi import Depends, Query
6
6
  from sqlalchemy.sql import Select, func, select
@@ -3,12 +3,13 @@ from contextlib import asynccontextmanager
3
3
  from typing import Annotated
4
4
 
5
5
  import structlog
6
- from fastapi import Depends, Request
6
+ from fastapi import Depends, Request, Response
7
7
  from fastapi.responses import PlainTextResponse
8
8
  from sqlalchemy import text
9
9
  from sqlalchemy.ext.asyncio import AsyncEngine
10
10
  from sqlalchemy.ext.asyncio import AsyncSession as SqlaAsyncSession
11
11
  from sqlalchemy.orm.session import sessionmaker
12
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
12
13
 
13
14
  from fastapi_sqla import aws_aurora_support, aws_rds_iam_support
14
15
  from fastapi_sqla.sqla import _DEFAULT_SESSION_KEY, Base, new_engine
@@ -88,9 +89,7 @@ async def open_session(
88
89
  await session.close()
89
90
 
90
91
 
91
- async def add_session_to_request(
92
- request: Request, call_next, key: str = _DEFAULT_SESSION_KEY
93
- ):
92
+ class AsyncSessionMiddleware:
94
93
  """Middleware which injects a new sqla async session into every request.
95
94
 
96
95
  Handles creation of session, as well as commit, rollback, and closing of session.
@@ -108,36 +107,58 @@ async def add_session_to_request(
108
107
  async def get_users(session: fastapi_sqla.AsyncSession):
109
108
  return await session.execute(...) # use your session here
110
109
  """
111
- async with open_session(key) as session:
112
- setattr(request.state, f"{_ASYNC_REQUEST_SESSION_KEY}_{key}", session)
113
- response = await call_next(request)
114
-
115
- is_dirty = bool(session.dirty or session.deleted or session.new)
116
-
117
- # try to commit after response, so that we can return a proper 500 response
118
- # and not raise a true internal server error
119
- if response.status_code < 400:
120
- try:
121
- await session.commit()
122
- except Exception:
123
- logger.exception("commit failed, returning http error")
124
- response = PlainTextResponse(
125
- content="Internal Server Error", status_code=500
126
- )
127
-
128
- if response.status_code >= 400:
129
- # If ever a route handler returns an http exception, we do not want the
130
- # session opened by current context manager to commit anything in db.
131
- if is_dirty:
132
- # optimistically only log if there were uncommitted changes
133
- logger.warning(
134
- "http error, rolling back possibly uncommitted changes",
135
- status_code=response.status_code,
136
- )
137
- # since this is no-op if session is not dirty, we can always call it
138
- await session.rollback()
139
110
 
140
- return response
111
+ def __init__(self, app: ASGIApp, key: str = _DEFAULT_SESSION_KEY) -> None:
112
+ self.app = app
113
+ self.key = key
114
+
115
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
116
+ if scope["type"] != "http":
117
+ return await self.app(scope, receive, send)
118
+
119
+ async with open_session(self.key) as session:
120
+ request = Request(scope=scope, receive=receive, send=send)
121
+ setattr(request.state, f"{_ASYNC_REQUEST_SESSION_KEY}_{self.key}", session)
122
+
123
+ async def send_wrapper(message: Message) -> None:
124
+ if message["type"] != "http.response.start":
125
+ return await send(message)
126
+
127
+ response: Response | None = None
128
+ status_code = message["status"]
129
+ is_dirty = bool(session.dirty or session.deleted or session.new)
130
+
131
+ # try to commit after response, so that we can return a proper 500
132
+ # and not raise a true internal server error
133
+ if status_code < 400:
134
+ try:
135
+ await session.commit()
136
+ except Exception:
137
+ logger.exception("commit failed, returning http error")
138
+ status_code = 500
139
+ response = PlainTextResponse(
140
+ content="Internal Server Error", status_code=500
141
+ )
142
+
143
+ if status_code >= 400:
144
+ # If ever a route handler returns an http exception,
145
+ # we do not want the current session to commit anything in db.
146
+ if is_dirty:
147
+ # optimistically only log if there were uncommitted changes
148
+ logger.warning(
149
+ "http error, rolling back possibly uncommitted changes",
150
+ status_code=status_code,
151
+ )
152
+ # since this is no-op if the session is not dirty,
153
+ # we can always call it
154
+ await session.rollback()
155
+
156
+ if response:
157
+ return await response(scope, receive, send)
158
+
159
+ return await send(message)
160
+
161
+ await self.app(scope, receive, send_wrapper)
141
162
 
142
163
 
143
164
  class AsyncSessionDependency:
@@ -20,7 +20,8 @@ def setup(engine: Engine):
20
20
  aws_rds_iam_enabled = lc_environ.get("fastapi_sqla_aws_rds_iam_enabled") == "true"
21
21
 
22
22
  if aws_rds_iam_enabled:
23
- assert boto3_installed, boto3_installed_err
23
+ if not boto3_installed:
24
+ raise ImportError(f"boto3 is required for RDS IAM : {boto3_installed_err}")
24
25
  # Cache the client at startup
25
26
  get_rds_client()
26
27
  event.listen(engine, "do_connect", set_connection_token)
@@ -2,6 +2,7 @@ import functools
2
2
  import os
3
3
  import re
4
4
 
5
+ from deprecated import deprecated
5
6
  from fastapi import FastAPI
6
7
  from sqlalchemy.engine import Engine
7
8
 
@@ -20,23 +21,41 @@ except ImportError as err: # pragma: no cover
20
21
  _ENGINE_KEYS_REGEX = re.compile(r"fastapi_sqla__(?!_)(.+)(?<!_)__(?!_).+")
21
22
 
22
23
 
24
+ async def startup():
25
+ engine_keys = _get_engine_keys()
26
+ engines = {key: sqla.new_engine(key) for key in engine_keys}
27
+ for key, engine in engines.items():
28
+ if not _is_async_dialect(engine):
29
+ sqla.startup(key=key)
30
+ else:
31
+ await async_sqla.startup(key=key)
32
+
33
+
34
+ def setup_middlewares(app: FastAPI):
35
+ engine_keys = _get_engine_keys()
36
+ engines = {key: sqla.new_engine(key) for key in engine_keys}
37
+ for key, engine in engines.items():
38
+ if not _is_async_dialect(engine):
39
+ app.add_middleware(sqla.SessionMiddleware, key=key)
40
+ else:
41
+ app.add_middleware(async_sqla.AsyncSessionMiddleware, key=key)
42
+
43
+
44
+ @deprecated(
45
+ reason="FastAPI events are deprecated. This function will be remove in the upcoming major release." # noqa: E501
46
+ )
23
47
  def setup(app: FastAPI):
24
48
  engine_keys = _get_engine_keys()
25
49
  engines = {key: sqla.new_engine(key) for key in engine_keys}
26
50
  for key, engine in engines.items():
27
51
  if not _is_async_dialect(engine):
28
52
  app.add_event_handler("startup", functools.partial(sqla.startup, key=key))
29
- app.middleware("http")(
30
- functools.partial(sqla.add_session_to_request, key=key)
31
- )
53
+ app.add_middleware(sqla.SessionMiddleware, key=key)
32
54
  else:
33
- assert has_asyncio_support, asyncio_support_err
34
55
  app.add_event_handler(
35
56
  "startup", functools.partial(async_sqla.startup, key=key)
36
57
  )
37
- app.middleware("http")(
38
- functools.partial(async_sqla.add_session_to_request, key=key)
39
- )
58
+ app.add_middleware(async_sqla.AsyncSessionMiddleware, key=key)
40
59
 
41
60
 
42
61
  def _get_engine_keys() -> set[str]:
@@ -3,7 +3,7 @@ from typing import Generic, TypeVar
3
3
  from pydantic import BaseModel, Field
4
4
  from pydantic import __version__ as pydantic_version
5
5
 
6
- major, _, _ = [int(v) for v in pydantic_version.split(".")]
6
+ major, _, _ = (int(v) for v in pydantic_version.split("."))
7
7
  is_pydantic2 = major == 2
8
8
  if is_pydantic2:
9
9
  GenericModel = BaseModel
@@ -1,7 +1,7 @@
1
1
  import math
2
- from collections.abc import Callable
2
+ from collections.abc import Callable, Iterator
3
3
  from functools import singledispatch
4
- from typing import Annotated, Iterator, Optional, Union, cast
4
+ from typing import Annotated, Optional, Union, cast
5
5
 
6
6
  from fastapi import Depends, Query
7
7
  from sqlalchemy.orm import Query as LegacyQuery
@@ -52,7 +52,7 @@ def paginate_query(
52
52
  limit: int,
53
53
  scalars: bool = True,
54
54
  ) -> Page: # pragma: no cover
55
- "Dispatch on registered functions based on `query` type"
55
+ """Dispatch on registered functions based on `query` type"""
56
56
  raise NotImplementedError(f"no paginate_query registered for type {type(query)!r}")
57
57
 
58
58
 
@@ -5,7 +5,7 @@ from contextlib import contextmanager
5
5
  from typing import Annotated
6
6
 
7
7
  import structlog
8
- from fastapi import Depends, Request
8
+ from fastapi import Depends, Request, Response
9
9
  from fastapi.concurrency import contextmanager_in_threadpool
10
10
  from fastapi.responses import PlainTextResponse
11
11
  from sqlalchemy import engine_from_config, text
@@ -13,6 +13,7 @@ from sqlalchemy.engine import Engine
13
13
  from sqlalchemy.ext.declarative import DeferredReflection
14
14
  from sqlalchemy.orm.session import Session as SqlaSession
15
15
  from sqlalchemy.orm.session import sessionmaker
16
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
16
17
 
17
18
  from fastapi_sqla import aws_aurora_support, aws_rds_iam_support
18
19
 
@@ -24,7 +25,7 @@ except ImportError:
24
25
  DeclarativeBase = declarative_base() # type: ignore
25
26
 
26
27
  try:
27
- from sqlmodel import Session as SqlaSession # type: ignore # noqa
28
+ from sqlmodel import Session as SqlaSession # type: ignore
28
29
 
29
30
  except ImportError:
30
31
  pass
@@ -110,9 +111,7 @@ def open_session(key: str = _DEFAULT_SESSION_KEY) -> Generator[SqlaSession, None
110
111
  session.close()
111
112
 
112
113
 
113
- async def add_session_to_request(
114
- request: Request, call_next, key: str = _DEFAULT_SESSION_KEY
115
- ):
114
+ class SessionMiddleware:
116
115
  """Middleware which injects a new sqla session into every request.
117
116
 
118
117
  Handles creation of session, as well as commit, rollback, and closing of session.
@@ -130,39 +129,60 @@ async def add_session_to_request(
130
129
  def get_users(session: fastapi_sqla.Session):
131
130
  return session.execute(...) # use your session here
132
131
  """
133
- async with contextmanager_in_threadpool(open_session(key)) as session:
134
- setattr(request.state, f"{_REQUEST_SESSION_KEY}_{key}", session)
135
-
136
- response = await call_next(request)
137
-
138
- is_dirty = bool(session.dirty or session.deleted or session.new)
139
-
140
- loop = asyncio.get_running_loop()
141
-
142
- # try to commit after response, so that we can return a proper 500 response
143
- # and not raise a true internal server error
144
- if response.status_code < 400:
145
- try:
146
- await loop.run_in_executor(None, session.commit)
147
- except Exception:
148
- logger.exception("commit failed, returning http error")
149
- response = PlainTextResponse(
150
- content="Internal Server Error", status_code=500
151
- )
152
-
153
- if response.status_code >= 400:
154
- # If ever a route handler returns an http exception, we do not want the
155
- # session opened by current context manager to commit anything in db.
156
- if is_dirty:
157
- # optimistically only log if there were uncommitted changes
158
- logger.warning(
159
- "http error, rolling back possibly uncommitted changes",
160
- status_code=response.status_code,
161
- )
162
- # since this is no-op if session is not dirty, we can always call it
163
- await loop.run_in_executor(None, session.rollback)
164
-
165
- return response
132
+
133
+ def __init__(self, app: ASGIApp, key: str = _DEFAULT_SESSION_KEY) -> None:
134
+ self.app = app
135
+ self.key = key
136
+
137
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
138
+ if scope["type"] != "http":
139
+ return await self.app(scope, receive, send)
140
+
141
+ async with contextmanager_in_threadpool(open_session(self.key)) as session:
142
+ request = Request(scope=scope, receive=receive, send=send)
143
+ setattr(request.state, f"{_REQUEST_SESSION_KEY}_{self.key}", session)
144
+
145
+ async def send_wrapper(message: Message) -> None:
146
+ if message["type"] != "http.response.start":
147
+ return await send(message)
148
+
149
+ response: Response | None = None
150
+ status_code = message["status"]
151
+ is_dirty = bool(session.dirty or session.deleted or session.new)
152
+
153
+ loop = asyncio.get_running_loop()
154
+
155
+ # try to commit after response, so that we can return a proper 500
156
+ # and not raise a true internal server error
157
+ if status_code < 400:
158
+ try:
159
+ await loop.run_in_executor(None, session.commit)
160
+ except Exception:
161
+ logger.exception("commit failed, returning http error")
162
+ status_code = 500
163
+ response = PlainTextResponse(
164
+ content="Internal Server Error", status_code=status_code
165
+ )
166
+
167
+ if status_code >= 400:
168
+ # If ever a route handler returns an http exception,
169
+ # we do not want the current session to commit anything in db.
170
+ if is_dirty:
171
+ # optimistically only log if there were uncommitted changes
172
+ logger.warning(
173
+ "http error, rolling back possibly uncommitted changes",
174
+ status_code=status_code,
175
+ )
176
+ # since this is no-op if the session is not dirty,
177
+ # we can always call it
178
+ await loop.run_in_executor(None, session.rollback)
179
+
180
+ if response:
181
+ return await response(scope, receive, send)
182
+
183
+ return await send(message)
184
+
185
+ await self.app(scope, receive, send_wrapper)
166
186
 
167
187
 
168
188
  class SessionDependency:
@@ -0,0 +1,206 @@
1
+ [tool.poetry]
2
+ name = "fastapi-sqla"
3
+ version = "3.4.8"
4
+ description = "SQLAlchemy extension for FastAPI with support for pagination, asyncio, SQLModel, and pytest, ready for production."
5
+ authors = [
6
+ "Hadrien David <hadrien.david@dialogue.co>",
7
+ "Victor Repkow <victor.repkow@dialogue.co>",
8
+ ]
9
+ license = "MIT"
10
+ readme = "README.md"
11
+ repository = "https://github.com/dialoguemd/fastapi-sqla"
12
+ keywords = ["FastAPI", "SQLAlchemy", "asyncio", "pytest", "alembic"]
13
+ classifiers = [
14
+ "Development Status :: 5 - Production/Stable",
15
+ "Environment :: Web Environment",
16
+ "Framework :: AsyncIO",
17
+ "Framework :: FastAPI",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Information Technology",
20
+ "Intended Audience :: System Administrators",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.9",
28
+ "Programming Language :: Python",
29
+ "Programming Language :: SQL",
30
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
31
+ "Topic :: Internet :: WWW/HTTP",
32
+ "Topic :: Internet",
33
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
34
+ "Topic :: Software Development :: Libraries :: Python Modules",
35
+ "Topic :: Software Development :: Libraries",
36
+ "Topic :: Software Development",
37
+ "Typing :: Typed",
38
+ ]
39
+
40
+ [tool.poetry.dependencies]
41
+ python = ">=3.9,<3.14"
42
+ fastapi = ">=0.95.1,<0.116"
43
+ pydantic = ">=1,<3"
44
+ sqlalchemy = ">=1.3,<3"
45
+ structlog = ">=20,<26"
46
+ deprecated = ">=1.2,<2"
47
+
48
+ alembic = { version = ">=1.4.3,<2", optional = true }
49
+ asyncpg = { version = ">=0.28.0,<0.31.0", optional = true }
50
+ boto3 = { version = ">=1.24.74,<2", optional = true }
51
+ psycopg2 = { version = ">=2.8.6,<3", optional = true }
52
+ sqlmodel = { version = ">=0.0.14,<0.0.23", optional = true }
53
+
54
+ [tool.poetry.group.dev.dependencies]
55
+ alembic = "1.14.0"
56
+ asgi_lifespan = "2.1.0"
57
+ Faker = "33.3.1"
58
+ greenlet = "3.1.1"
59
+ httpx = "0.28.1"
60
+ mypy = { version = "1.14.1", extras = ["tests"] }
61
+ psycopg2 = { version = "2.9.10", extras = ["binary"] }
62
+ pytest = "8.3.4"
63
+ pytest-asyncio = "0.25.2"
64
+ pytest-cov = "6.0.0"
65
+ ruff = "0.9.2"
66
+ tox = "4.23.2"
67
+
68
+ [tool.poetry.extras]
69
+ asyncpg = ["asyncpg"]
70
+ aws_rds_iam = ["boto3"]
71
+ pytest_plugin = ["alembic"]
72
+ psycopg2 = ["psycopg2"]
73
+ sqlmodel = ["sqlmodel"]
74
+
75
+ [build-system]
76
+ requires = ["poetry>=1.3.0"]
77
+ build-backend = "poetry.masonry.api"
78
+
79
+ [tool.semantic_release]
80
+ version_toml = ["pyproject.toml:tool.poetry.version"]
81
+ upload_to_pypi = false
82
+ commit_message = "{version}\n\nVersion generated by python-semantic-release [ci skip]"
83
+
84
+ [tool.poetry.plugins."pytest11"]
85
+ fastapi-sqla = "fastapi_sqla._pytest_plugin"
86
+
87
+ [tool.ruff]
88
+ target-version = "py39"
89
+
90
+ [tool.ruff.lint]
91
+ select = [
92
+ "E", # pycodestyle
93
+ "W", # pycodestyle warnings
94
+ "F", # pyflakes
95
+ "I", # isort
96
+ "N", # pep8-naming
97
+ "D", # pydocstyle
98
+ "UP", # pyupgrade
99
+ "ASYNC", # flake8-async
100
+ "S", # flake8-bandit
101
+ "B", # flake8-bugbear
102
+ "C4", # flake8-comprehensions
103
+ "SIM", # flake8-simplify
104
+ "TD", # flake8-todos
105
+ "ERA", # eradicate
106
+ "PL", # pylint
107
+ "RUF", # ruff
108
+ ]
109
+
110
+ # Ignore some pydocstyle rules that Google convention enables
111
+ ignore = [
112
+ "D100",
113
+ "D101",
114
+ "D102",
115
+ "D103",
116
+ "D104",
117
+ "D105",
118
+ "D106",
119
+ "D107",
120
+ "D212",
121
+ "D415",
122
+ "B008", # Enable using functions in default function args. Easier to work with FastAPI dependencies that way
123
+ "C408", # Enable using dict/list/tuple
124
+ "PLR0911", # Disable max number of return
125
+ "PLR0912", # Disable max number of branches
126
+ "PLR0913", # Disable max number of args
127
+ "PLR0915", # Disable max number of statements
128
+ "PLR2004", # Enable magic values
129
+ "SIM105", # Allow using try - except - pass
130
+ "TD001", # Enable FIXMEs
131
+ "TD002", # Enable TODOs without author
132
+ "TD003", # Enable TODOs without issue link
133
+ "N802", # FastAPI dependency conventions is title cased
134
+ "N803", # Same as N802
135
+ "N806", # Same as N802
136
+ ]
137
+
138
+ [tool.ruff.lint.extend-per-file-ignores]
139
+ "tests/*" = [
140
+ "S101", # Enable assert
141
+ "S105", # Disable passwords check
142
+ "S106", # Disable passwords check
143
+ "S608", # Allow SQL string construction
144
+ ]
145
+
146
+ [tool.ruff.lint.pydocstyle]
147
+ convention = "google"
148
+
149
+ [tool.pytest.ini_options]
150
+ asyncio_mode = "auto"
151
+ testpaths = "tests"
152
+ norecursedirs = ".git,.venv"
153
+ xfail_strict = "true"
154
+ addopts = """
155
+ -p no:fastapi-sqla
156
+ --cov-config pyproject.toml
157
+ --cov-report term
158
+ --cov-report term-missing
159
+ """
160
+
161
+ # https://nedbatchelder.com/blog/201810/why_warnings_is_mysterious.html
162
+ filterwarnings = ["error:.*removed in version 2.0.*:"]
163
+
164
+ [tool.pytest-watch.run]
165
+ ext = ".py, .yaml, .cfg"
166
+
167
+ [tool.coverage.run]
168
+ branch = true
169
+ omit = ["tests/*", ".venv/*"]
170
+ concurrency = ["thread", "greenlet"]
171
+
172
+ [tool.coverage.report]
173
+ skip_covered = true
174
+
175
+ [tool.tox]
176
+ legacy_tox_ini = """
177
+ [tox]
178
+ envlist = sqlalchemy{ 1.3, 1.4, 2.0, 2.0-sqlmodel }-{ asyncpg, noasyncpg }-{aws_rds_iam, noaws_rds_iam }-pydantic{ 1, 2 }
179
+
180
+ [testenv]
181
+ passenv = CI
182
+ skip_install = true
183
+ allowlist_externals = poetry
184
+ commands_pre =
185
+ poetry install
186
+ sqlmodel: poetry install --extras "sqlmodel"
187
+ aws_rds_iam: poetry install --extras "aws_rds_iam"
188
+ asyncpg: poetry install --extras "asyncpg"
189
+ asyncpg-aws_rds_iam: poetry install --extras "asyncpg aws_rds_iam"
190
+ sqlmodel-aws_rds_iam: poetry install --extras "sqlmodel aws_rds_iam"
191
+ sqlmodel-asyncpg: poetry install --extras "sqlmodel asyncpg"
192
+ sqlmodel-asyncpg-aws_rds_iam: poetry install --extras "sqlmodel asyncpg aws_rds_iam"
193
+ sqlalchemy1.3: pip install sqlalchemy==1.3
194
+ sqlalchemy1.4: pip install sqlalchemy==1.4.52
195
+ pydantic1: pip install pydantic==1.10.16
196
+ commands =
197
+ poetry run pytest -vv --showlocals --cov . --cov-report xml --cov-report html --junitxml=test-reports/pytest/junit.xml
198
+ """
199
+
200
+ [tool.mypy]
201
+ exclude = ["tests"]
202
+ plugins = ["sqlalchemy.ext.mypy.plugin"]
203
+
204
+ [[tool.mypy.overrides]]
205
+ module = ["asyncpg", "boto3", "deprecated"]
206
+ ignore_missing_imports = true
@@ -1,132 +0,0 @@
1
- [tool]
2
- [tool.poetry]
3
- name = "fastapi-sqla"
4
- version = "3.1.2"
5
- description = "SQLAlchemy extension for FastAPI with support for pagination, asyncio, SQLModel, and pytest, ready for production."
6
- authors = ["Hadrien David <hadrien.david@dialogue.co>", "Victor Repkow <victor.repkow@dialogue.co>"]
7
- license = "MIT"
8
- readme = "README.md"
9
- repository = "https://github.com/dialoguemd/fastapi-sqla"
10
- keywords = ["FastAPI", "SQLAlchemy", "asyncio", "pytest", "alembic"]
11
- classifiers = ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.9", "Programming Language :: Python", "Programming Language :: SQL", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries", "Topic :: Software Development", "Typing :: Typed"]
12
- [tool.poetry.dependencies]
13
- python = "^3.9"
14
- fastapi = ">=0.95.1"
15
- pydantic = ">=1"
16
- sqlalchemy = ">=1.3"
17
- structlog = ">=20"
18
- [tool.poetry.dependencies.alembic]
19
- version = "^1.4.3"
20
- optional = true
21
- [tool.poetry.dependencies.asgi_lifespan]
22
- version = "^1.0.1"
23
- optional = true
24
- [tool.poetry.dependencies.asyncpg]
25
- version = "^0.28.0"
26
- optional = true
27
- [tool.poetry.dependencies.black]
28
- version = "^22.8.0"
29
- optional = true
30
- [tool.poetry.dependencies.Faker]
31
- version = "^14.2.0"
32
- optional = true
33
- [tool.poetry.dependencies.httpx]
34
- version = "^0.23.0"
35
- optional = true
36
- [tool.poetry.dependencies.isort]
37
- version = "^5.5.3"
38
- optional = true
39
- [tool.poetry.dependencies.pdbpp]
40
- version = "^0.10.2"
41
- optional = true
42
- [tool.poetry.dependencies.psycopg2]
43
- version = "^2.8.6"
44
- optional = true
45
- [tool.poetry.dependencies.pylama]
46
- version = "^8.4.1"
47
- optional = true
48
- [tool.poetry.dependencies.pytest]
49
- version = "^7.2.1"
50
- optional = true
51
- [tool.poetry.dependencies.pytest-asyncio]
52
- version = "^0.19.0"
53
- optional = true
54
- [tool.poetry.dependencies.pytest-cov]
55
- version = "^2.10.1"
56
- optional = true
57
- [tool.poetry.dependencies.tox]
58
- version = "^3.26.0"
59
- optional = true
60
- [tool.poetry.dependencies.boto3]
61
- version = "^1.24.74"
62
- optional = true
63
- [tool.poetry.dependencies.greenlet]
64
- version = "^1.1.3"
65
- optional = true
66
- [tool.poetry.dependencies.mypy]
67
- version = "^1.0.0"
68
- extras = ["tests"]
69
- optional = true
70
- [tool.poetry.dependencies.sqlmodel]
71
- version = "^0.0.14"
72
- optional = true
73
- [tool.poetry.extras]
74
- tests = ["alembic", "asgi_lifespan", "black", "coverage", "Faker", "greenlet", "httpx", "isort", "mypy", "pdbpp", "psycopg2", "pylama", "pytest", "pytest-asyncio", "pytest-cov", "pytest-watch", "tox"]
75
- asyncpg = ["asyncpg"]
76
- aws_rds_iam = ["boto3"]
77
- sqlmodel = ["sqlmodel"]
78
- [tool.poetry.plugins]
79
- [tool.poetry.plugins.pytest11]
80
- fastapi-sqla = "fastapi_sqla._pytest_plugin"
81
-
82
- [[tool.poetry.source]]
83
- name = "dialogue-private"
84
- url = "https://dialogue-527641002329.d.codeartifact.us-east-1.amazonaws.com/pypi/distribution-readonly/simple/"
85
- default = true
86
- [tool.semantic_release]
87
- version_variable = "pyproject.toml:version"
88
- upload_to_pypi = false
89
- commit_message = "Version generated by python-semantic-release [ci skip]"
90
- [tool.pylama]
91
- paths = "fastapi_sqla tests"
92
- linters = "pycodestyle,mccabe,pyflakes"
93
- [tool.pylama.linter]
94
- [tool.pylama.linter.pycodestyle]
95
- max_line_length = 88
96
- [tool.isort]
97
- profile = "black"
98
- lines_between_sections = 1
99
- multi_line_output = 3
100
- include_trailing_comma = "True"
101
- line_length = 88
102
- [tool.pytest]
103
- [tool.pytest.ini_options]
104
- asyncio_mode = "auto"
105
- testpaths = "tests"
106
- norecursedirs = ".git,.venv"
107
- xfail_strict = "true"
108
- addopts = " -p no:fastapi-sqla\n --cov-config pyproject.toml\n --cov-report term\n --cov-report term-missing\n"
109
- filterwarnings = ["error:.*removed in version 2.0.*:"]
110
- [tool.pytest-watch]
111
- [tool.pytest-watch.run]
112
- ext = ".py, .yaml, .cfg"
113
- [tool.coverage]
114
- [tool.coverage.run]
115
- branch = true
116
- omit = ["tests/*", ".venv/*"]
117
- concurrency = ["thread", "greenlet"]
118
- [tool.coverage.report]
119
- skip_covered = true
120
- [tool.tox]
121
- legacy_tox_ini = "[tox]\nenvlist = sqlalchemy{ 1.3, 1.4, 2.0, 2.0-sqlmodel }-{ asyncpg, noasyncpg }-{aws_rds_iam, noaws_rds_iam }-pydantic{ 1, 2 }\n\n[testenv]\npassenv = CI\ndeps =\n sqlalchemy1.3: sqlalchemy<1.4\n sqlalchemy1.4: sqlalchemy>=1.4,<2\n sqlalchemy2.0: sqlalchemy>=2\n sqlalchemy2.0-sqlmodel: sqlalchemy>=2\n sqlalchemy2.0-sqlmodel: sqlmodel\n asyncpg: asyncpg\n aws_rds_iam: boto3\n pydantic1: pydantic<2\n pydantic2: pydantic>=2\n\nextras =\n tests\n\ncommands = pytest -vv --cov={envsitepackagesdir}/fastapi_sqla --cov-report xml --cov-report html --junitxml=test-reports/pytest/junit.xml\n"
122
- [tool.black]
123
- exclude = ".mypy_cache|.pytest_cache|.vscode|.eggs|venv"
124
- --skip-magic-trailing-comma = true
125
- [tool.mypy]
126
- exclude = ["tests"]
127
- ignore_missing_imports = true
128
- plugins = "sqlalchemy.ext.mypy.plugin"
129
-
130
- [build-system]
131
- requires = ["poetry>=1.3.0"]
132
- build-backend = "poetry.masonry.api"
File without changes