From bc9bd5c27982a56e75301a9d40a7741de1a0bb90 Mon Sep 17 00:00:00 2001 From: Eugene Shershen Date: Sun, 10 May 2026 17:20:57 +0300 Subject: [PATCH 1/6] added concurrent operations are not permitted --- .github/workflows/ci.yml | 36 ++++++++ tests/test_concurrent_queries_postgres.py | 108 ++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tests/test_concurrent_queries_postgres.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f87750d..fe26f17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,42 @@ jobs: file: ./coverage.xml token: ${{ secrets.CODECOV_TOKEN }} + test-postgres: + name: test-postgres + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip wheel + pip install -r requirements.txt + + - name: Run PostgreSQL concurrent-queries tests + env: + POSTGRES_TEST_URL: postgresql+asyncpg://test:test@localhost:5432/test + run: pytest tests/test_concurrent_queries_postgres.py -v + ruff: name: ruff runs-on: ubuntu-latest diff --git a/tests/test_concurrent_queries_postgres.py b/tests/test_concurrent_queries_postgres.py new file mode 100644 index 0000000..c640dca --- /dev/null +++ b/tests/test_concurrent_queries_postgres.py @@ -0,0 +1,108 @@ +"""Real PostgreSQL/asyncpg reproduction of the `isce` concurrent-operations error. + +The production traceback originates here:: + + sqlalchemy.exc.InvalidRequestError: This session is provisioning a new + connection; concurrent operations are not permitted + (https://sqlalche.me/e/20/isce) + +The error is triggered by ``await asyncio.gather(session.execute(...), +session.execute(...))`` against a single AsyncSession backed by asyncpg. +SQLite/aiosqlite serialises internally and does NOT reliably reproduce it +(see ``test_concurrent_queries.py``), so this module targets a real Postgres +instance and is skipped unless ``POSTGRES_TEST_URL`` is set. + +Run locally:: + + POSTGRES_TEST_URL="postgresql+asyncpg://user:pass@localhost:5432/test" \\ + pytest tests/test_concurrent_queries_postgres.py -v +""" + +import asyncio +import os +import uuid + +import pytest +from sqlalchemy import text + +POSTGRES_URL = os.getenv("POSTGRES_TEST_URL") + +pytestmark = pytest.mark.skipif( + not POSTGRES_URL, + reason="POSTGRES_TEST_URL not set; this test requires a real PostgreSQL/asyncpg instance", +) + + +@pytest.fixture +def table_name(): + return f"isce_repro_{uuid.uuid4().hex[:8]}" + + +@pytest.fixture +async def setup_table(app, db, SQLAlchemyMiddleware, table_name): + SQLAlchemyMiddleware(app, db_url=POSTGRES_URL) + async with db(commit_on_exit=True): + await db.session.execute( + text(f"CREATE TABLE {table_name} (id SERIAL PRIMARY KEY, name TEXT NOT NULL)") + ) + await db.session.execute( + text(f"INSERT INTO {table_name} (name) SELECT 'row_' || g FROM generate_series(1, 50) g") + ) + try: + yield + finally: + async with db(commit_on_exit=True): + await db.session.execute(text(f"DROP TABLE IF EXISTS {table_name}")) + + +@pytest.mark.asyncio +async def test_gather_on_same_session_raises_isce(db, table_name, setup_table): + """asyncio.gather() on a single AsyncSession must raise isce on asyncpg.""" + count_stmt = text(f"SELECT COUNT(*) FROM {table_name}") + rows_stmt = text(f"SELECT id, name FROM {table_name} LIMIT 10") + + with pytest.raises(Exception) as exc_info: + async with db(): + await asyncio.gather( + db.session.execute(count_stmt), + db.session.execute(rows_stmt), + ) + + error_msg = str(exc_info.value).lower() + assert ( + "concurrent operations are not permitted" in error_msg + or "provisioning a new connection" in error_msg + or "isce" in error_msg + ), f"Expected isce error, got: {exc_info.value!r}" + + +@pytest.mark.asyncio +async def test_db_gather_multi_sessions_avoids_isce(db, table_name, setup_table): + """db.gather() in multi_sessions mode gives each task its own session β€” no isce.""" + count_stmt = text(f"SELECT COUNT(*) FROM {table_name}") + rows_stmt = text(f"SELECT id, name FROM {table_name} LIMIT 10") + + async def get_count(): + result = await db.session.execute(count_stmt) + return result.scalar() + + async def get_rows(): + result = await db.session.execute(rows_stmt) + return result.fetchall() + + async with db(multi_sessions=True, max_concurrent=2): + count, rows = await db.gather(get_count(), get_rows()) + + assert count == 50 + assert len(rows) == 10 + + +@pytest.mark.asyncio +async def test_sequential_execute_on_same_session_works(db, table_name, setup_table): + """Sequential awaits on the same session never trigger isce.""" + async with db(): + count_result = await db.session.execute(text(f"SELECT COUNT(*) FROM {table_name}")) + rows_result = await db.session.execute(text(f"SELECT id FROM {table_name} LIMIT 5")) + + assert count_result.scalar() == 50 + assert len(rows_result.fetchall()) == 5 From 17c1f45b0ebacf3810c27b017f8c2633312e76e1 Mon Sep 17 00:00:00 2001 From: Eugene Shershen Date: Sun, 14 Jun 2026 21:43:35 +0300 Subject: [PATCH 2/6] fix lint --- .gitignore | 1 + tests/test_concurrent_queries_postgres.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4409b7a..7537e16 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ CLAUDE.md .DS_Store .kiro/ test.db +openspec/ diff --git a/tests/test_concurrent_queries_postgres.py b/tests/test_concurrent_queries_postgres.py index c640dca..b4c9ed3 100644 --- a/tests/test_concurrent_queries_postgres.py +++ b/tests/test_concurrent_queries_postgres.py @@ -46,7 +46,10 @@ async def setup_table(app, db, SQLAlchemyMiddleware, table_name): text(f"CREATE TABLE {table_name} (id SERIAL PRIMARY KEY, name TEXT NOT NULL)") ) await db.session.execute( - text(f"INSERT INTO {table_name} (name) SELECT 'row_' || g FROM generate_series(1, 50) g") + text( + f"INSERT INTO {table_name} (name) " + "SELECT 'row_' || g FROM generate_series(1, 50) g" + ) ) try: yield From abf657473cc4c7deba7e5c4b2cbe48700dc1043b Mon Sep 17 00:00:00 2001 From: Eugene Shershen Date: Sun, 14 Jun 2026 23:31:23 +0300 Subject: [PATCH 3/6] docs --- .github/workflows/docs.yml | 60 ++++++++++ .gitignore | 1 + README.md | 3 + docs/api-reference.md | 202 +++++++++++++++++++++++++++++++++ docs/assets/extra.css | 138 ++++++++++++++++++++++ docs/assets/favicon.svg | 8 ++ docs/faq.md | 193 +++++++++++++++++++++++++++++++ docs/getting-started.md | 117 +++++++++++++++++++ docs/guide/concurrency.md | 127 +++++++++++++++++++++ docs/guide/engine-lifecycle.md | 101 +++++++++++++++++ docs/guide/events.md | 85 ++++++++++++++ docs/guide/index.md | 87 ++++++++++++++ docs/guide/multi-database.md | 102 +++++++++++++++++ docs/guide/sessions.md | 137 ++++++++++++++++++++++ docs/guide/streaming.md | 102 +++++++++++++++++ docs/guide/type-hints.md | 91 +++++++++++++++ docs/index.md | 126 ++++++++++++++++++++ mkdocs.yml | 124 ++++++++++++++++++++ requirements-docs.txt | 2 + 19 files changed, 1806 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/api-reference.md create mode 100644 docs/assets/extra.css create mode 100644 docs/assets/favicon.svg create mode 100644 docs/faq.md create mode 100644 docs/getting-started.md create mode 100644 docs/guide/concurrency.md create mode 100644 docs/guide/engine-lifecycle.md create mode 100644 docs/guide/events.md create mode 100644 docs/guide/index.md create mode 100644 docs/guide/multi-database.md create mode 100644 docs/guide/sessions.md create mode 100644 docs/guide/streaming.md create mode 100644 docs/guide/type-hints.md create mode 100644 docs/index.md create mode 100644 mkdocs.yml create mode 100644 requirements-docs.txt diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e896e3d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,60 @@ +name: docs + +on: + push: + branches: + - main + paths: + - "docs/**" + - "mkdocs.yml" + - "requirements-docs.txt" + - ".github/workflows/docs.yml" + workflow_dispatch: + +# Allow the GITHUB_TOKEN to deploy to GitHub Pages. +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; don't cancel an in-progress production deploy. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # so `git-revision-date` style plugins can read history + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install documentation dependencies + run: pip install -r requirements-docs.txt + + - name: Build site (strict) + run: mkdocs build --strict + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 7537e16..358d694 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ dist/ build/ +site/ .vscode/ venv/ .idea/ diff --git a/README.md b/README.md index cb3f4f4..829a1f1 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,14 @@ [![pip](https://img.shields.io/pypi/v/fastapi_async_sqlalchemy?color=blue)](https://pypi.org/project/fastapi-async-sqlalchemy/) [![Downloads](https://static.pepy.tech/badge/fastapi-async-sqlalchemy)](https://pepy.tech/project/fastapi-async-sqlalchemy) [![Updates](https://pyup.io/repos/github/h0rn3t/fastapi-async-sqlalchemy/shield.svg)](https://pyup.io/repos/github/h0rn3t/fastapi-async-sqlalchemy/) +[![Docs](https://img.shields.io/badge/docs-mkdocs--material-0e8a8a)](https://h0rn3t.github.io/fastapi-async-sqlalchemy/) ### Description Provides SQLAlchemy middleware for FastAPI using AsyncSession and async engine. +πŸ“– **Full documentation: ** + ### Install ```bash diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..98e51b6 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,202 @@ +# API Reference + +Everything the package exports from `fastapi_async_sqlalchemy`. + +```python +from fastapi_async_sqlalchemy import ( + SQLAlchemyMiddleware, + db, + create_middleware_and_session_proxy, + DBSessionMeta, + DBSessionType, +) +``` + +--- + +## `SQLAlchemyMiddleware` + +ASGI middleware that opens a request-scoped `AsyncSession` and finalizes it when +the response is sent. Add it with `app.add_middleware(...)`. + +```python +app.add_middleware( + SQLAlchemyMiddleware, + db_url=None, + custom_engine=None, + engine_args=None, + session_args=None, + commit_on_exit=False, +) +``` + +**Parameters** + +| Name | Type | Default | Description | +| ---------------- | ------------------------ | ------- | ------------------------------------------------------------------------------------------- | +| `db_url` | `str \| URL \| None` | `None` | Database URL. The middleware creates and **owns** the engine. Mutually exclusive with `custom_engine`. | +| `custom_engine` | `AsyncEngine \| None` | `None` | A pre-built engine you **own**. The middleware uses it but never disposes it. | +| `engine_args` | `dict \| None` | `None` | Forwarded to `create_async_engine` (only when `db_url` is used). E.g. `pool_size`, `echo`. | +| `session_args` | `dict \| None` | `None` | Forwarded to `async_sessionmaker`. | +| `commit_on_exit` | `bool` | `False` | Commit the request session on a clean exit. See [Sessions](guide/sessions.md#commit-on-exit). | + +!!! warning "Exactly one engine source" + Passing neither `db_url` nor `custom_engine` raises `ValueError`. Binding a + proxy that is already bound to a different live engine raises `RuntimeError` + β€” use [`create_middleware_and_session_proxy()`](#create_middleware_and_session_proxy) + for additional databases. + +### `await middleware.dispose()` + +Dispose the middleware-owned engine. No-op for a `custom_engine`. Idempotent on +success and safe to retry on failure. Call it manually when running outside an +ASGI lifespan. See [Engine Lifecycle](guide/engine-lifecycle.md#manual-disposal-outside-a-lifespan). + +--- + +## `db` + +The global session proxy. Its public API lives on its metaclass; annotate it +with [`DBSessionMeta`](#dbsessionmeta-dbsessiontype). + +### `db.session` + +: **Property** β†’ `AsyncSession` + + The session bound to the current async context. + + - In `multi_sessions` mode, returns the calling task's own session. + - Raises [`SessionNotInitialisedError`](#exceptions) if no middleware has + been constructed yet. + - Raises [`MissingSessionError`](#exceptions) if there is no active request + or `async with db()` context. + +### `db(...)` + +: **Call** β†’ async context manager + + Open an explicit session context. + + ```python + db( + session_args=None, + commit_on_exit=False, + multi_sessions=False, + max_concurrent=None, + ) + ``` + + | Name | Type | Default | Description | + | ---------------- | ------------------- | ------- | ----------------------------------------------------------------- | + | `session_args` | `dict \| None` | `None` | Per-context overrides for the sessionmaker. | + | `commit_on_exit` | `bool` | `False` | Commit on a clean exit of the block. | + | `multi_sessions` | `bool` | `False` | Give each task its own session. See [Concurrency](guide/concurrency.md). | + | `max_concurrent` | `int \| None` | `None` | Cap simultaneous sessions. Must be `>= 1`, else `ValueError`. | + + ```python + async with db(commit_on_exit=True): + db.session.add(obj) + ``` + +### `db.connection()` + +: **Method** β†’ async context manager yielding `AsyncSession` + + Throttled session access for `multi_sessions` mode. Waits for a semaphore + slot (when `max_concurrent` is set) before creating a session, then releases + it on exit. Without `max_concurrent` it simply creates and cleans up a + session. + + ```python + async with db(multi_sessions=True, max_concurrent=10): + async with db.connection() as session: + await session.execute(text("SELECT 1")) + ``` + +### `await db.gather(*coros, return_exceptions=False)` + +: **Coroutine** β†’ `list` + + Pool-aware drop-in for `asyncio.gather`. Each coroutine acquires a slot (and + a session) before running and releases it afterwards, so no more than + `max_concurrent` connections are in use at once. + + - Accepts **coroutine objects only** when `max_concurrent` is set; + pre-created `Task`/`Future` inputs raise `TypeError`. + - With no `max_concurrent`, delegates directly to `asyncio.gather`. + + ```python + async with db(multi_sessions=True, max_concurrent=10): + results = await db.gather(*(work(i) for i in range(100))) + ``` + +--- + +## `create_middleware_and_session_proxy()` + +```python +create_middleware_and_session_proxy() -> tuple[type, DBSessionMeta] +``` + +Build a fresh, fully isolated `(middleware_class, db_proxy)` pair with its own +`ContextVar` state and engine binding. Use one pair per independent app or +database. + +```python +FirstMiddleware, first_db = create_middleware_and_session_proxy() +SecondMiddleware, second_db = create_middleware_and_session_proxy() +``` + +The package's default exports are created exactly this way: + +```python +SQLAlchemyMiddleware, db = create_middleware_and_session_proxy() +``` + +See [Multiple Databases](guide/multi-database.md). + +--- + +## `DBSessionMeta` / `DBSessionType` + +A structural `Protocol` (at type-check time) and the runtime metaclass of `db` +(at runtime). Use as an annotation for the `db` proxy. `DBSessionType` is an +alias of `DBSessionMeta`. See [Type Hints](guide/type-hints.md). + +```python +def get_db() -> DBSessionMeta: + return db +``` + +--- + +## Exceptions + +Both live in `fastapi_async_sqlalchemy` (and `fastapi_async_sqlalchemy.exceptions`). + +### `MissingSessionError` + +Raised when `db.session` is accessed with **no active session context** β€” you're +not in a request and haven't opened `async with db()`. + +```python +async with db(): + await db.session.execute(foo.select()) # βœ… fix +``` + +### `SessionNotInitialisedError` + +Raised when `db.session` is accessed before any `SQLAlchemyMiddleware` has been +**constructed**, so the sessionmaker doesn't exist yet. Ensure the middleware is +added during app setup. + +--- + +## Version + +The installed version is available as: + +```python +import fastapi_async_sqlalchemy +fastapi_async_sqlalchemy.__version__ +``` diff --git a/docs/assets/extra.css b/docs/assets/extra.css new file mode 100644 index 0000000..cac359f --- /dev/null +++ b/docs/assets/extra.css @@ -0,0 +1,138 @@ +/* ========================================================================== + FastAPI Async SQLAlchemy β€” custom theme layer on top of Material + ========================================================================== */ + +:root { + --fas-teal: #0e8a8a; + --fas-teal-bright: #12b3b3; + --fas-ink: #0b2027; +} + +/* Brand color tuning for the teal primary palette */ +[data-md-color-primary="teal"] { + --md-primary-fg-color: var(--fas-teal); + --md-primary-fg-color--light: var(--fas-teal-bright); + --md-primary-fg-color--dark: #0a6b6b; +} + +/* -------------------------------------------------------------------------- + Hero banner (home page) + -------------------------------------------------------------------------- */ +.fas-hero { + margin: -1.2rem -0.8rem 2.5rem; + padding: 4rem 1.5rem 3.5rem; + text-align: center; + color: #fff; + background: + radial-gradient(1200px 400px at 50% -10%, rgba(255, 255, 255, 0.18), transparent 60%), + linear-gradient(135deg, #0a6b6b 0%, #0e8a8a 45%, #12b3b3 100%); + border-radius: 0 0 24px 24px; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.08); +} + +.fas-hero h1 { + font-size: 2.7rem; + font-weight: 800; + line-height: 1.1; + margin: 0 0 0.6rem; + color: #fff; + letter-spacing: -0.02em; +} + +.fas-hero p { + max-width: 40rem; + margin: 0 auto 1.6rem; + font-size: 1.05rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.92); +} + +.fas-hero .fas-cta { + display: inline-flex; + flex-wrap: wrap; + gap: 0.7rem; + justify-content: center; +} + +.fas-hero .md-button { + border-radius: 999px; + font-weight: 600; +} + +.fas-hero .md-button--primary { + background: #fff; + color: var(--fas-teal) !important; + border-color: #fff; +} + +.fas-hero .md-button:not(.md-button--primary) { + color: #fff; + border-color: rgba(255, 255, 255, 0.7); +} + +.fas-hero .md-button:not(.md-button--primary):hover { + background: rgba(255, 255, 255, 0.14); + border-color: #fff; +} + +.fas-badges { + margin-top: 1.6rem; + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: center; +} + +.fas-badges img { + height: 20px; +} + +/* -------------------------------------------------------------------------- + Feature cards (Material "grid cards" enhancement) + -------------------------------------------------------------------------- */ +.md-typeset .grid.cards > ul > li, +.md-typeset .grid.cards > :is(ul, ol) > li { + border-radius: 14px; + border: 1px solid var(--md-default-fg-color--lightest); + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.md-typeset .grid.cards > ul > li:hover { + border-color: var(--fas-teal-bright); + box-shadow: 0 10px 28px -16px rgba(14, 138, 138, 0.55); + transform: translateY(-2px); +} + +.md-typeset .grid.cards .twemoji, +.md-typeset .grid.cards .lg { + color: var(--fas-teal); +} + +/* -------------------------------------------------------------------------- + Code & inline polish + -------------------------------------------------------------------------- */ +.md-typeset :not(pre) > code { + border-radius: 5px; +} + +.md-typeset .admonition, +.md-typeset details { + border-radius: 10px; +} + +/* Tables breathe a little more */ +.md-typeset table:not([class]) { + border-radius: 10px; + overflow: hidden; +} + +/* Footer / nav tab accent */ +.md-tabs { + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.12); +} + +@media screen and (max-width: 44.9em) { + .fas-hero h1 { + font-size: 2rem; + } +} diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg new file mode 100644 index 0000000..aba2d08 --- /dev/null +++ b/docs/assets/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..8ed5b76 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,193 @@ +# FAQ & Troubleshooting + +Common errors, what they mean, and how to fix them. + +## `MissingSessionError` + +> No session found! Either you are not currently in a request context, or you +> need to manually create a session context… + +You accessed `db.session` outside any session context. This happens in startup +hooks, scripts, background tasks, or tests. + +**Fix** β€” wrap the access: + +```python +async with db(): + result = await db.session.execute(foo.select()) +``` + +See [Sessions & Contexts](guide/sessions.md#outside-a-request-async-with-db). + +--- + +## `SessionNotInitialisedError` + +> Session not initialised! Ensure that DBSessionMiddleware has been initialised… + +You accessed `db.session` before any `SQLAlchemyMiddleware` was constructed, so +the sessionmaker doesn't exist yet. + +**Fix** β€” make sure the middleware is added during app setup, before the access +runs: + +```python +app.add_middleware(SQLAlchemyMiddleware, db_url="...") +``` + +In tests, construct the middleware (or run the app lifespan) before touching +`db.session`. + +--- + +## `TimeoutError: QueuePool limit ... reached` + +You launched more concurrent sessions than your pool can serve. + +**Fix** β€” throttle with `max_concurrent` and route child sessions through +`db.gather()` or `db.connection()`: + +```python +async with db(multi_sessions=True, max_concurrent=10): + results = await db.gather(*(work(i) for i in range(1000))) +``` + +See [Concurrent Queries](guide/concurrency.md#throttling-with-max_concurrent). + +--- + +## `InvalidRequestError: ... concurrent operations are not permitted` + +Two coroutines used the **same** `AsyncSession` at once (e.g. `asyncio.gather` +over `db.session`). A session can only do one thing at a time. + +**Fix** β€” give each task its own session with `multi_sessions=True`: + +```python +async with db(multi_sessions=True): + async def worker(n): + return await db.session.execute(text(f"SELECT {n}")) + await asyncio.gather(*(worker(i) for i in range(5))) +``` + +--- + +## `TypeError` from `db.gather()` + +> When `max_concurrent` is set, db.gather() accepts coroutine objects only… + +You passed a `Task` or `Future` to `db.gather()` while `max_concurrent` is set. +Those may already be running outside the semaphore. + +**Fix** β€” pass **coroutine objects**, not tasks: + +```python +# ❌ await db.gather(*[asyncio.create_task(work(i)) for i in range(10)]) +# βœ… +await db.gather(*(work(i) for i in range(10))) +``` + +Or manage your own tasks with `db.connection()` inside each. + +--- + +## `RuntimeError: ... child tasks must access DB via db.connection() or db.gather()` + +With `max_concurrent` set, a **child task** accessed `db.session` directly. That +path isn't throttled. + +**Fix** β€” open the session through `db.connection()` (or use `db.gather()`): + +```python +async with db.connection() as session: + await session.execute(text("SELECT 1")) +``` + +The **parent** task may still use `db.session` directly. + +--- + +## `RuntimeError: ... commit_on_exit=True cannot use the ... session with a streaming response` + +You returned a `StreamingResponse` while `commit_on_exit=True` and the request +session had already been used. + +**Fix** β€” own the database lifetime inside the generator, or commit before +streaming. See [Streaming Responses](guide/streaming.md). + +```python +async def body(): + async with db(): + result = await db.session.stream(foo.select()) + async for row in result: + yield serialize(row) +``` + +--- + +## `RuntimeError: This SQLAlchemy session proxy is already bound to another live engine` + +You added two middlewares built from the **same** proxy with different engines, +or reused the default pair for a second database. + +**Fix** β€” create an independent pair per database: + +```python +from fastapi_async_sqlalchemy import create_middleware_and_session_proxy + +SecondMiddleware, second_db = create_middleware_and_session_proxy() +``` + +See [Multiple Databases](guide/multi-database.md#one-proxy-one-live-engine). + +--- + +## Connections aren't released on shutdown + +If you constructed `SQLAlchemyMiddleware(db_url=...)` outside an ASGI lifespan +(a script or ad-hoc harness), nothing triggers disposal. + +**Fix** β€” dispose manually: + +```python +middleware = SQLAlchemyMiddleware(app, db_url="...") +try: + ... +finally: + await middleware.dispose() +``` + +For a `custom_engine`, **you** own disposal: `await engine.dispose()`. See +[Engine Lifecycle](guide/engine-lifecycle.md). + +--- + +## Graceful shutdown hangs + +Engine disposal runs before the lifespan shutdown ack is forwarded, so draining +a stuck pool blocks shutdown. + +**Fix** β€” set your ASGI server's graceful-shutdown timeout to cover the +worst-case connection close time, e.g. uvicorn: + +```bash +uvicorn main:app --timeout-graceful-shutdown 30 +``` + +--- + +## Does it work with SQLModel? + +Yes. If `sqlmodel` is installed, the middleware uses its `AsyncSession` subclass +automatically β€” no configuration needed. + +--- + +## Can I run the docs locally? + +```bash +pip install -r requirements-docs.txt +mkdocs serve +``` + +Then open . diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..f2cdff9 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,117 @@ +# Getting Started + +This page takes you from an empty project to a running FastAPI app backed by an +async SQLAlchemy session in a few minutes. + +## Requirements + +- **Python** 3.12 or newer +- **starlette** β‰₯ 0.40 and **SQLAlchemy** β‰₯ 2.0 (installed automatically) +- An **async database driver** for your engine + +| Database | Driver | Example URL | +| ---------- | ------------ | ------------------------------------------------------- | +| PostgreSQL | `asyncpg` | `postgresql+asyncpg://user:pass@localhost:5432/app` | +| MySQL | `aiomysql` | `mysql+aiomysql://user:pass@localhost:3306/app` | +| SQLite | `aiosqlite` | `sqlite+aiosqlite:///./app.db` | + +## Install + +```bash +pip install fastapi-async-sqlalchemy + +# plus a driver, e.g. PostgreSQL +pip install asyncpg +``` + +It also works out of the box with [`sqlmodel`](https://sqlmodel.tiangolo.com/) β€” +if `sqlmodel` is installed, its `AsyncSession` subclass is used automatically. + +## Your first app + +```python title="main.py" +from fastapi import FastAPI +from fastapi_async_sqlalchemy import SQLAlchemyMiddleware, db +from sqlalchemy import column, table + +app = FastAPI() + +app.add_middleware( + SQLAlchemyMiddleware, + db_url="sqlite+aiosqlite:///./app.db", +) + +# a lightweight table reference for the example +files = table("ms_files", column("id")) + + +@app.get("/files") +async def list_files(): + result = await db.session.execute(files.select()) + return result.fetchall() +``` + +Run it: + +```bash +uvicorn main:app --reload +``` + +Open . The middleware opened a session for the +request, made it available as `db.session`, and closed it when the response was +sent. + +!!! tip "That's the whole idea" + You never created or passed a session. `db.session` is bound to the current + request via a `ContextVar`, so it resolves correctly even from helper + functions called deep inside the request. + +## Configuring the engine + +Pass engine and session options through `engine_args` / `session_args`. These +are forwarded verbatim to SQLAlchemy's +[`create_async_engine`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) +and `async_sessionmaker`. + +```python +app.add_middleware( + SQLAlchemyMiddleware, + db_url="postgresql+asyncpg://user:pass@localhost:5432/app", + engine_args={ + "echo": True, # log every SQL statement + "pool_pre_ping": True, # validate connections before use + "pool_size": 5, # connections kept open + "max_overflow": 10, # extra connections allowed above pool_size + }, + commit_on_exit=True, # commit the request session on a clean exit +) +``` + +## Using the session outside a request + +Outside the request/response cycle (startup hooks, scripts, workers) there is no +middleware to open a session for you. Open one explicitly with the `db` context +manager: + +```python +async def warm_cache(): + async with db(): + result = await db.session.execute(files.select()) + return result.fetchall() + + +@app.on_event("startup") +async def on_startup(): + await warm_cache() +``` + +## Next steps + +
+ +- :material-database-sync: [__Sessions & contexts__](guide/sessions.md) β€” how `db.session` and `async with db()` behave +- :material-engine: [__Engine lifecycle__](guide/engine-lifecycle.md) β€” ownership, disposal, graceful shutdown +- :material-arrow-decision: [__Concurrent queries__](guide/concurrency.md) β€” run parallel queries safely +- :material-book-open-variant: [__API reference__](api-reference.md) β€” every public symbol + +
diff --git a/docs/guide/concurrency.md b/docs/guide/concurrency.md new file mode 100644 index 0000000..4bf7c97 --- /dev/null +++ b/docs/guide/concurrency.md @@ -0,0 +1,127 @@ +# Concurrent Queries + +A single `AsyncSession` cannot run two operations at once β€” concurrent use +raises SQLAlchemy's `InvalidRequestError: This session is provisioning a new +connection; concurrent operations are not permitted`. To run queries in +**parallel** you need a session **per task**, and you need to keep the number of +simultaneous sessions under your connection-pool limit. + +`multi_sessions` mode solves both problems. + +## The problem with sharing one session + +```python +# ❌ Don't do this β€” all tasks share the one request session +async def bad(): + await asyncio.gather( + db.session.execute(text("SELECT 1")), + db.session.execute(text("SELECT 2")), # concurrent op on same session + ) +``` + +## `multi_sessions=True` + +Opening `db(multi_sessions=True)` switches `db.session` to give **each task its +own session**, tracked and cleaned up by the middleware: + +```python +import asyncio +from sqlalchemy import text + +async def run(): + async with db(multi_sessions=True): + async def worker(n: int): + # each task gets a distinct session + return await db.session.execute(text(f"SELECT {n}")) + + await asyncio.gather(*(worker(i) for i in range(5))) +``` + +Child task sessions are committed/rolled back and closed for you as each task +finishes. All child tasks **must complete before the `async with` block exits**. + +## Throttling with `max_concurrent` + +Unbounded parallelism will exhaust the pool and raise +`TimeoutError: QueuePool limit ... reached`. Set `max_concurrent` to cap the +number of sessions holding a connection at once. When you do, child tasks must +acquire their session through **`db.connection()`** or **`db.gather()`** so the +middleware owns both the session lifetime and the semaphore slot. + +### `db.gather()` β€” the easy path + +A drop-in, pool-aware replacement for `asyncio.gather`. Each coroutine acquires +a slot (and a session) before it runs and releases it afterwards: + +```python +async def do_work(n: int) -> int: + async with db.connection() as session: + result = await session.execute(text(f"SELECT {n}")) + return result.scalar_one() + + +async def run(): + async with db(multi_sessions=True, max_concurrent=10): + results = await db.gather(*(do_work(i) for i in range(100))) + # never more than 10 connections in flight +``` + +!!! warning "Pass coroutines, not Tasks/Futures" + When `max_concurrent` is set, `db.gather()` accepts **coroutine objects + only**. A pre-created `Task` or `Future` may already be running outside the + semaphore, so it is rejected with `TypeError`. Pass `do_work(i)`, not + `asyncio.create_task(do_work(i))`. + +### `db.connection()` β€” explicit slots + +When you create your own tasks, open the session inside each task with +`db.connection()`. It waits for a free slot before creating the session and +releases it when the block exits: + +```python +async def run(): + async with db(multi_sessions=True, max_concurrent=10): + async def execute_query(query: str): + async with db.connection() as session: + return await session.execute(text(query)) + + tasks = [ + asyncio.create_task(execute_query(f"SELECT {i}")) + for i in range(50) + ] + await asyncio.gather(*tasks) +``` + +Without `max_concurrent`, `db.connection()` still works β€” it just creates a +session without throttling and cleans it up on exit. + +## Rules to remember + +- Child tasks that use the database **must finish before** the owning + `async with db(multi_sessions=True)` block exits. Tasks still parked on the + semaphore (or still running) when the block starts closing are cancelled. +- With `max_concurrent` set, **direct `db.session` access from a child task is + rejected** β€” it isn't throttled. Use `db.connection()` or `db.gather()` + instead. (The parent task may still use `db.session` directly.) +- Creating a new `db.connection()` session **after** the context has begun + closing raises `RuntimeError`. +- `max_concurrent` must be `>= 1`, otherwise `ValueError` is raised. + +## Choosing an approach + +```mermaid +flowchart TD + A[Need parallel DB work?] -->|no| B[Use db.session directly] + A -->|yes| C{Bounded by pool?} + C -->|"just a few tasks"| D["db(multi_sessions=True)"] + C -->|"many tasks"| E["db(multi_sessions=True, max_concurrent=N)"] + E --> F{Own your tasks?} + F -->|no, pass coroutines| G["db.gather(...)"] + F -->|yes, create_task| H["db.connection() inside each task"] +``` + +| Scenario | Use | +| ------------------------------------------ | ---------------------------------------------- | +| A handful of parallel queries | `db(multi_sessions=True)` + `db.session` | +| Many queries, cap connections, simplest | `db(multi_sessions=True, max_concurrent=N)` + `db.gather()` | +| Many queries, you manage the tasks | `db(multi_sessions=True, max_concurrent=N)` + `db.connection()` | diff --git a/docs/guide/engine-lifecycle.md b/docs/guide/engine-lifecycle.md new file mode 100644 index 0000000..c593105 --- /dev/null +++ b/docs/guide/engine-lifecycle.md @@ -0,0 +1,101 @@ +# Engine Lifecycle + +The middleware can either **own** the SQLAlchemy async engine or **borrow** one +you created. Ownership decides who disposes the connection pool β€” and getting +this wrong leaks connections on shutdown. This page makes the rules explicit. + +## Two ways to provide an engine + +=== "Middleware owns the engine (`db_url`)" + + ```python + app.add_middleware( + SQLAlchemyMiddleware, + db_url="postgresql+asyncpg://user:pass@localhost/app", + engine_args={"pool_size": 5, "max_overflow": 10}, + ) + ``` + + The middleware calls `create_async_engine(db_url, **engine_args)`, **owns** + the result, and disposes it during ASGI lifespan shutdown. + +=== "You own the engine (`custom_engine`)" + + ```python + from sqlalchemy.ext.asyncio import create_async_engine + + engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/app") + app.add_middleware(SQLAlchemyMiddleware, custom_engine=engine) + + # later, in your own shutdown / test cleanup: + await engine.dispose() + ``` + + The middleware uses your engine but **never disposes it**. Disposal is your + responsibility. + +!!! note "One of the two is required" + You must pass either `db_url` or `custom_engine`. Passing neither raises + `ValueError`. + +## Disposal during ASGI lifespan + +For a middleware-owned engine (`db_url`), disposal is automatic. It happens when +the lifespan ends β€” including failure paths β€” so a raising shutdown handler +can't leak the pool: + +- `lifespan.shutdown.complete` +- `lifespan.shutdown.failed` +- `lifespan.startup.failed` + +The engine is disposed **once**, for the application lifetime β€” not per request. + +```python +from fastapi.testclient import TestClient + +# Running the lifespan (e.g. via TestClient context) triggers disposal: +with TestClient(app): + ... +# <- engine disposed here +``` + +!!! warning "Disposal blocks the shutdown ack" + Engine disposal runs **before** the lifespan acknowledgement is forwarded to + the ASGI server, so a slow pool drain delays graceful shutdown. Configure + your server's graceful-shutdown timeout (e.g. uvicorn's + `--timeout-graceful-shutdown`) to cover the worst-case time to close active + connections. + +## Manual disposal outside a lifespan + +When you build `SQLAlchemyMiddleware(db_url=...)` **outside** an ASGI lifespan β€” +a script, an ad-hoc harness, a non-ASGI runtime β€” there is no +`lifespan.shutdown` event, so nothing triggers disposal. Call `dispose()` +yourself: + +```python +middleware = SQLAlchemyMiddleware(app, db_url="postgresql+asyncpg://...") +try: + ... # use db.session +finally: + await middleware.dispose() +``` + +`dispose()` is: + +- **Idempotent on success** β€” calling it again is a no-op. +- **Safe to retry on failure** β€” the proxy's session bindings are cleared + deterministically, so a later call actually re-attempts `engine.dispose()` + rather than silently no-op'ing on a half-disposed engine. +- **A no-op for borrowed engines** β€” if you passed `custom_engine`, `dispose()` + does nothing; you own that engine. + +The same guidance applies to each pair returned by +[`create_middleware_and_session_proxy()`](multi-database.md). + +## Summary + +| You pass | Engine created by | Disposed by | When | +| ---------------- | ----------------- | ------------------------------- | -------------------------------------- | +| `db_url` | the middleware | the middleware | lifespan shutdown, or `dispose()` | +| `custom_engine` | you | you (`await engine.dispose()`) | whenever your own cleanup runs | diff --git a/docs/guide/events.md b/docs/guide/events.md new file mode 100644 index 0000000..70ba4bd --- /dev/null +++ b/docs/guide/events.md @@ -0,0 +1,85 @@ +# SQLAlchemy Events + +SQLAlchemy's [event system](https://docs.sqlalchemy.org/en/20/orm/events.html) +is **independent of the session and engine**. This middleware doesn't change how +events fire β€” register listeners on your mapped classes (or on `Mapper` / +`Session`) with `sqlalchemy.event.listens_for` exactly as you would in a +synchronous SQLAlchemy setup. + +## Registering listeners + +```python +from datetime import datetime +from sqlalchemy import Column, DateTime, Integer, String, event +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + username = Column(String(50), unique=True, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow) + + +@event.listens_for(User, "before_insert") +def normalize(mapper, connection, target): + target.username = target.username.lower().strip() + + +@event.listens_for(User, "before_update") +def touch_updated_at(mapper, connection, target): + target.updated_at = datetime.utcnow() + + +@event.listens_for(User, "after_insert") +def log_insert(mapper, connection, target): + print(f"user created: id={target.id}") +``` + +These fire when the session flushes, just like always. + +## :warning: Mapper events are synchronous + +Mapper-level events receive a **synchronous** `connection` argument: + +`before_insert` Β· `after_insert` Β· `before_update` Β· `after_update` Β· +`before_delete` Β· `after_delete` + +Inside these handlers: + +- **Do not** `await` anything. +- **Do not** call async ORM APIs. + +```python +@event.listens_for(User, "before_insert") +def handler(mapper, connection, target): + # βœ… pure, synchronous work on `target` + target.username = target.username.strip().lower() + # ❌ await db.session.execute(...) <- not allowed here +``` + +If you need async work after a write, do it **after** `await +db.session.commit()` returns, or use `Session`-level events such as +`after_flush` / `after_commit` and schedule the async work from there. + +## A complete example + +A runnable example with validation, automatic timestamps, audit logging, and a +commented-out soft-delete hook lives in the repository at +[`examples/events_example.py`](https://github.com/h0rn3t/fastapi-async-sqlalchemy/blob/main/examples/events_example.py). +It wires the listeners above into a small FastAPI CRUD app: + +```python +@app.post("/users") +async def create_user(username: str, email: str, full_name: str | None = None): + async with db(): + user = User(username=username, email=email, full_name=full_name) + db.session.add(user) + await db.session.commit() # before_insert / after_insert fire on flush + return {"id": user.id, "username": user.username} +``` diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 0000000..a682eab --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,87 @@ +# User Guide + +The middleware is small but covers several distinct concerns. Pick the topic you +need β€” each page is self-contained. + +
+ +- :material-database-sync:{ .lg .middle } __Sessions & Contexts__ + + --- + + The `db.session` proxy, the `async with db()` context manager, + `commit_on_exit`, and when each session is created and closed. + + [:octicons-arrow-right-24: Read](sessions.md) + +- :material-engine:{ .lg .middle } __Engine Lifecycle__ + + --- + + Who owns the engine (`db_url` vs `custom_engine`), when it is disposed, and + how to dispose it manually outside an ASGI lifespan. + + [:octicons-arrow-right-24: Read](engine-lifecycle.md) + +- :material-arrow-decision:{ .lg .middle } __Concurrent Queries__ + + --- + + `multi_sessions`, `max_concurrent`, `db.gather()` and `db.connection()` β€” + parallel work without blowing past the connection pool. + + [:octicons-arrow-right-24: Read](concurrency.md) + +- :material-download-network:{ .lg .middle } __Streaming Responses__ + + --- + + Why streaming bodies need their own session and how to write one safely. + + [:octicons-arrow-right-24: Read](streaming.md) + +- :material-transit-connection-variant:{ .lg .middle } __Multiple Databases__ + + --- + + `create_middleware_and_session_proxy()` for independent apps or databases. + + [:octicons-arrow-right-24: Read](multi-database.md) + +- :material-bell-ring:{ .lg .middle } __SQLAlchemy Events__ + + --- + + Using `before_insert` / `after_update` and friends with async sessions. + + [:octicons-arrow-right-24: Read](events.md) + +- :material-language-python:{ .lg .middle } __Type Hints__ + + --- + + Annotate `db` with `DBSessionMeta` for full mypy / IDE support. + + [:octicons-arrow-right-24: Read](type-hints.md) + +
+ +## Mental model + +```mermaid +flowchart LR + A[HTTP request] --> B[SQLAlchemyMiddleware] + B -->|opens| C[AsyncSession bound to ContextVar] + C --> D[Route / service code
reads db.session] + D --> E{clean exit?} + E -->|yes + commit_on_exit| F[commit] + E -->|exception| G[rollback] + F --> H[close session] + G --> H + H --> I[response sent] +``` + +The session lives for the duration of the request context. Everything in the +guide is a variation on this: opening extra contexts (`async with db()`), +running many sessions at once (`multi_sessions`), or moving the lifetime into a +streaming body. diff --git a/docs/guide/multi-database.md b/docs/guide/multi-database.md new file mode 100644 index 0000000..568b1f0 --- /dev/null +++ b/docs/guide/multi-database.md @@ -0,0 +1,102 @@ +# Multiple Databases + +The default `SQLAlchemyMiddleware` / `db` pair is bound to **one** engine. To +talk to several independent databases, create a separate middleware/session +proxy pair for each with `create_middleware_and_session_proxy()`. + +## Create one pair per database + +```python title="databases.py" +from fastapi_async_sqlalchemy import create_middleware_and_session_proxy + +FirstSQLAlchemyMiddleware, first_db = create_middleware_and_session_proxy() +SecondSQLAlchemyMiddleware, second_db = create_middleware_and_session_proxy() +``` + +Each call returns an independent `(middleware_class, db_proxy)` tuple with its +own `ContextVar` state and engine binding. + +!!! info "The default pair is just a pre-made instance" + `SQLAlchemyMiddleware, db = create_middleware_and_session_proxy()` is exactly + how the package builds the defaults it exports. Calling it yourself gives you + additional, fully isolated pairs. + +## Wire them into the app + +```python title="main.py" +from fastapi import FastAPI + +from databases import FirstSQLAlchemyMiddleware, SecondSQLAlchemyMiddleware +from routes import router + +app = FastAPI() +app.include_router(router) + +app.add_middleware( + FirstSQLAlchemyMiddleware, + db_url="postgresql+asyncpg://user:pass@localhost:5432/primary_db", + engine_args={"pool_size": 5, "max_overflow": 10}, +) +app.add_middleware( + SecondSQLAlchemyMiddleware, + db_url="mysql+aiomysql://user:pass@localhost:3306/secondary_db", + engine_args={"pool_size": 5, "max_overflow": 10}, +) +``` + +## Use the right proxy in each route + +```python title="routes.py" +from fastapi import APIRouter +from sqlalchemy import column, table + +from databases import first_db, second_db + +router = APIRouter() +files = table("ms_files", column("id")) + + +@router.get("/first-db-files") +async def get_files_from_first_db(): + result = await first_db.session.execute(files.select()) + return result.fetchall() + + +@router.get("/second-db-files") +async def get_files_from_second_db(): + result = await second_db.session.execute(files.select()) + return result.fetchall() +``` + +Each proxy resolves its own request-scoped session, so `first_db.session` and +`second_db.session` never collide. + +## One proxy, one live engine + +A proxy is **bound to a single live engine**. Reusing the same proxy with a +different live engine is rejected: + +```text +RuntimeError: This SQLAlchemy session proxy is already bound to another live +engine. Use create_middleware_and_session_proxy() for independent apps or +databases. +``` + +This guard exists so requests can never silently switch to a different database +binding. The fix is to use a **fresh pair** per app or database β€” exactly what +`create_middleware_and_session_proxy()` is for. + +!!! tip "Rebinding after disposal" + The binding is cleared when the owning middleware's engine is disposed (via + lifespan shutdown or `await middleware.dispose()`). After disposal the proxy + is free to bind a new engine β€” useful in test suites that build and tear down + an app per test. + +## Concurrency still applies per proxy + +Each proxy supports the full [concurrency API](concurrency.md) independently: + +```python +async with first_db(multi_sessions=True, max_concurrent=10): + results = await first_db.gather(*(work(i) for i in range(100))) +``` diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md new file mode 100644 index 0000000..89fd84a --- /dev/null +++ b/docs/guide/sessions.md @@ -0,0 +1,137 @@ +# Sessions & Contexts + +Everything in this library revolves around one idea: **`db.session` is an +`AsyncSession` bound to the current async context.** This page explains how that +session is created, where it lives, and how to open your own contexts. + +## The `db` proxy + +`db` is a global object exported from the package: + +```python +from fastapi_async_sqlalchemy import db +``` + +It exposes a small surface: + +| Member | Kind | Purpose | +| ------------------ | --------------------- | ---------------------------------------------------- | +| `db.session` | property | The `AsyncSession` for the current context | +| `db(...)` | callable | Open an explicit session context manager | +| `db.connection()` | method | Throttled session context manager (multi-session) | +| `db.gather(...)` | coroutine | Pool-aware `asyncio.gather` (multi-session) | + +`db.session` is backed by a [`ContextVar`][contextvar], so each request β€” each +independent async context β€” sees its own session. You never pass it around. + +## Inside a request + +When `SQLAlchemyMiddleware` is installed, every HTTP request gets a session +opened **before** your route runs and finalized **after** it returns: + +```python +@app.get("/items/{item_id}") +async def get_item(item_id: int): + item = await db.session.get(Item, item_id) + return item +``` + +The same session is visible from any function called during the request, no +arguments required: + +```python +async def load_item(item_id: int) -> Item | None: + # same session as the route β€” resolved from the request context + return await db.session.get(Item, item_id) + + +@app.get("/items/{item_id}") +async def get_item(item_id: int): + return await load_item(item_id) +``` + +### Commit on exit + +By default the request session is **not** committed for you β€” you call +`await db.session.commit()` yourself. Set `commit_on_exit=True` to commit +automatically when the request finishes cleanly: + +```python +app.add_middleware( + SQLAlchemyMiddleware, + db_url="postgresql+asyncpg://user:pass@localhost/app", + commit_on_exit=True, +) +``` + +The finalization rules are: + +- **Clean exit + `commit_on_exit=True`** β†’ `commit()`, then `close()`. +- **Clean exit + `commit_on_exit=False`** (default) β†’ just `close()` (uncommitted + work is rolled back by closing). +- **Exception** β†’ `rollback()`, then `close()`. The original exception + propagates; a failure during rollback/commit/close is surfaced too. + +!!! warning "Commit/rollback errors are not swallowed" + If `commit()` fails, the middleware attempts a `rollback()` and raises, so a + write failure can never be reported to the client as success. + +## Outside a request: `async with db()` + +Anywhere there is no request context β€” startup/shutdown hooks, CLI scripts, +background tasks, tests β€” open a session explicitly: + +```python +async def get_db_fetch(): + async with db(): + result = await db.session.execute(foo.select()) + return result.fetchall() +``` + +`db()` accepts the same finalization options as the middleware: + +```python +async with db(commit_on_exit=True): + db.session.add(User(name="ada")) + # committed automatically on a clean exit +``` + +You can also pass `session_args` to override sessionmaker arguments for that one +context: + +```python +async with db(session_args={"expire_on_commit": True}): + ... +``` + +### `MissingSessionError` + +Accessing `db.session` with no active context raises +[`MissingSessionError`](../api-reference.md#exceptions): + +```python +# ❌ no request, no `async with db()` +result = await db.session.execute(foo.select()) # MissingSessionError +``` + +The fix is always the same β€” wrap the access in a context: + +```python +async with db(): + result = await db.session.execute(foo.select()) +``` + +### `SessionNotInitialisedError` + +If you access `db.session` before any `SQLAlchemyMiddleware` has been +constructed (so the sessionmaker doesn't exist yet), you get +[`SessionNotInitialisedError`](../api-reference.md#exceptions) instead. Make sure +`app.add_middleware(SQLAlchemyMiddleware, ...)` runs during app setup. + +## Where to go next + +- Run **many** sessions at once β†’ [Concurrent Queries](concurrency.md) +- Stream a large response body β†’ [Streaming Responses](streaming.md) +- Understand engine ownership and shutdown β†’ [Engine Lifecycle](engine-lifecycle.md) + +[contextvar]: https://docs.python.org/3/library/contextvars.html diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md new file mode 100644 index 0000000..adffb82 --- /dev/null +++ b/docs/guide/streaming.md @@ -0,0 +1,102 @@ +# Streaming Responses + +A `StreamingResponse` (or `FileResponse`) has a **different lifetime** from a +normal request transaction. The body keeps yielding chunks *after* your route +function returns, but the middleware-managed request session is tied to the +request transaction β€” not to the stream. So you must not rely on `db.session` +staying open while a streaming body runs. + +The rule: **open an explicit session inside the generator** so the body owns its +own database lifetime. + +## The right way + +```python +from fastapi.responses import StreamingResponse +from fastapi_async_sqlalchemy import db + +@app.get("/export") +async def export(): + async def rows(): + async with db(): # body-owned session + result = await db.session.stream(foo.select()) + async for row in result: + yield f"{row.id}\n".encode() + + return StreamingResponse(rows(), media_type="text/plain") +``` + +The `async with db()` inside the generator makes the session lifetime explicit +and keeps the session open for the whole body. + +## Why not `commit_on_exit=True`? + +Implicit `commit_on_exit=True` is **not** a safe way to report streaming write +success. The response may have already started β€” and early chunks already sent β€” +before an unbounded body finishes. A late commit failure cannot un-send those +chunks. + +To enforce this, the middleware actively rejects the unsafe combination. If a +streaming response begins while `commit_on_exit=True` **and** the request +session was already used, it raises: + +```text +RuntimeError: `commit_on_exit=True` cannot use the middleware-managed request +database session with a streaming response. Use `async with db()` inside the +streaming generator, or manage the streaming transaction explicitly. +``` + +Similarly, once the request session has been closed for streaming, touching it +again raises a `RuntimeError` telling you to use `async with db()` inside the +generator. + +## If a streaming route needs to write + +Pick one of two explicit patterns: + +=== "Commit before streaming" + + Complete and commit the write in its own context **before** creating the + streaming response, then stream read-only: + + ```python + @app.post("/report") + async def make_report(): + async with db(commit_on_exit=True): + db.session.add(ReportRun(status="started")) + # committed here, before any streaming begins + + async def body(): + async with db(): + result = await db.session.stream(rows.select()) + async for row in result: + yield serialize(row) + + return StreamingResponse(body()) + ``` + +=== "Write inside the generator" + + Make the generator own an explicit write transaction and design the API so + clients don't treat early chunks as confirmation of a completed write: + + ```python + @app.get("/stream-and-write") + async def stream_and_write(): + async def body(): + async with db(commit_on_exit=True): + async for row in produce(): + db.session.add(AuditRow(data=row)) + yield row + # committed when the generator's context exits + + return StreamingResponse(body()) + ``` + +## Migrating existing code + +If you previously used `db.session` directly inside a streaming generator, move +that code into a generator-owned `async with db()` context as shown above. This +keeps database access available for the whole body while making it clear that +the session lifetime belongs to the stream, not the original request +transaction. diff --git a/docs/guide/type-hints.md b/docs/guide/type-hints.md new file mode 100644 index 0000000..5ce1eba --- /dev/null +++ b/docs/guide/type-hints.md @@ -0,0 +1,91 @@ +# Type Hints + +The package ships a `py.typed` marker, so type checkers read its inline +annotations. The one piece worth knowing about is how to annotate the `db` +proxy itself. + +## Annotating `db` with `DBSessionMeta` + +Use `DBSessionMeta` (or its alias `DBSessionType`) when you need to type a +function or attribute that holds the `db` proxy: + +```python +from fastapi_async_sqlalchemy import DBSessionMeta, db + + +def get_db() -> DBSessionMeta: + return db +``` + +This gives static checkers (mypy, pyright) and your IDE full autocomplete for +the proxy surface β€” `session`, `connection()`, `gather()` and the `db(...)` +call. + +## Runtime vs. type-check behavior + +`DBSessionMeta` is deliberately two things at once: + +- **At runtime** it is the actual metaclass of `db`, so identity and instance + checks work as they did in earlier versions: + + ```python + from fastapi_async_sqlalchemy import DBSessionMeta, db + + assert isinstance(db, DBSessionMeta) + assert type(db) is DBSessionMeta + ``` + +- **At type-check time** it resolves to a structural + [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol) + describing the public API. That's what powers autocomplete and `mypy` + checking when you annotate with it. + +The Protocol surface is: + +```python +class DBSessionMeta(Protocol): + @property + def session(self) -> AsyncSession: ... + + def connection(self) -> AbstractAsyncContextManager[AsyncSession]: ... + + async def gather( + self, *coros_or_futures: Any, return_exceptions: bool = ... + ) -> list[Any]: ... + + def __call__( + self, + session_args: dict[str, Any] | None = ..., + commit_on_exit: bool = ..., + multi_sessions: bool = ..., + max_concurrent: int | None = ..., + ) -> AbstractAsyncContextManager[Any]: ... +``` + +`DBSessionType` is an exported alias of `DBSessionMeta` β€” use whichever name +reads better in your codebase. + +## Dependency-injection style + +If you prefer passing `db` explicitly (e.g. for testability) rather than +importing the global, the annotation makes it first-class: + +```python +from fastapi import Depends +from fastapi_async_sqlalchemy import DBSessionMeta, db + + +def get_db() -> DBSessionMeta: + return db + + +async def list_users(database: DBSessionMeta = Depends(get_db)): + result = await database.session.execute(users.select()) + return result.fetchall() +``` + +## Works with SQLModel + +If `sqlmodel` is installed, the middleware uses `sqlmodel`'s `AsyncSession` +subclass automatically, so `db.session` exposes SQLModel's session API. No extra +configuration is needed. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..7fce099 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,126 @@ +--- +hide: + - navigation + - toc +--- + +
+ +# FastAPI Async SQLAlchemy + +Drop-in async SQLAlchemy middleware for FastAPI. A request-scoped +`AsyncSession` you reach through a single global `db` β€” no per-route +dependency wiring, no manual session plumbing. + +
+[Get started :material-arrow-right:](getting-started.md){ .md-button .md-button--primary } +[View on GitHub](https://github.com/h0rn3t/fastapi-async-sqlalchemy){ .md-button } +
+ +
+[![PyPI](https://img.shields.io/pypi/v/fastapi_async_sqlalchemy?color=0e8a8a&label=pypi)](https://pypi.org/project/fastapi-async-sqlalchemy/) +[![Downloads](https://static.pepy.tech/badge/fastapi-async-sqlalchemy)](https://pepy.tech/project/fastapi-async-sqlalchemy) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![CI](https://github.com/h0rn3t/fastapi-async-sqlalchemy/workflows/ci/badge.svg)](https://github.com/h0rn3t/fastapi-async-sqlalchemy/actions) +
+ +
+ +## Why this middleware? + +SQLAlchemy's `AsyncSession` is not safe to share across concurrent tasks, and +FastAPI gives you a fresh request per coroutine. This middleware binds **one +session to each request context** using a Python [`ContextVar`][contextvar], so +`db.session` always resolves to the right session for the request you're in β€” +whether you access it from a route, a service function, or a background helper. + +```python +from fastapi import FastAPI +from fastapi_async_sqlalchemy import SQLAlchemyMiddleware, db +from sqlalchemy import text + +app = FastAPI() +app.add_middleware( + SQLAlchemyMiddleware, + db_url="postgresql+asyncpg://user:pass@localhost:5432/app", +) + +@app.get("/ping") +async def ping(): + result = await db.session.execute(text("SELECT 1")) + return {"db": result.scalar()} +``` + +No `Depends(get_session)`, no passing the session down every call. Access the +session anywhere in the request with `db.session`. + +## Features + +
+ +- :material-database-sync:{ .lg .middle } __Request-scoped sessions__ + + --- + + A `ContextVar`-backed `AsyncSession` per request. Reach it from anywhere + with `db.session` β€” no dependency injection boilerplate. + + [:octicons-arrow-right-24: Sessions & contexts](guide/sessions.md) + +- :material-engine:{ .lg .middle } __Engine lifecycle done right__ + + --- + + Pass a `db_url` and the middleware owns and disposes the engine on + shutdown; pass a `custom_engine` and you keep ownership. + + [:octicons-arrow-right-24: Engine lifecycle](guide/engine-lifecycle.md) + +- :material-arrow-decision:{ .lg .middle } __Pool-throttled concurrency__ + + --- + + Run many queries in parallel without exhausting the pool. `db.gather()` + and `db.connection()` cap in-flight sessions at `max_concurrent`. + + [:octicons-arrow-right-24: Concurrent queries](guide/concurrency.md) + +- :material-transit-connection-variant:{ .lg .middle } __Multiple databases__ + + --- + + Build independent middleware/proxy pairs with + `create_middleware_and_session_proxy()` β€” one per database. + + [:octicons-arrow-right-24: Multiple databases](guide/multi-database.md) + +- :material-download-network:{ .lg .middle } __Streaming-aware__ + + --- + + Clear rules for `StreamingResponse` so the session lifetime belongs to the + body, not a closed request transaction. + + [:octicons-arrow-right-24: Streaming responses](guide/streaming.md) + +- :material-language-python:{ .lg .middle } __Typed & SQLModel-ready__ + + --- + + Ships `py.typed`, a `DBSessionMeta` Protocol for full autocomplete, and + works transparently with `sqlmodel`. + + [:octicons-arrow-right-24: Type hints](guide/type-hints.md) + +
+ +## Installation + +```bash +pip install fastapi-async-sqlalchemy +``` + +Requires Python 3.12+, `starlette>=0.40`, and `SQLAlchemy>=2.0`. Add the async +driver for your database (`asyncpg`, `aiomysql`, `aiosqlite`, …). + +[contextvar]: https://docs.python.org/3/library/contextvars.html diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..accc708 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,124 @@ +site_name: FastAPI Async SQLAlchemy +site_description: >- + Async SQLAlchemy middleware for FastAPI β€” a request-scoped AsyncSession + proxy with multi-database, pool-throttled concurrency, streaming and engine + lifecycle management. +site_author: Eugene Shershen +site_url: https://h0rn3t.github.io/fastapi-async-sqlalchemy/ + +repo_name: h0rn3t/fastapi-async-sqlalchemy +repo_url: https://github.com/h0rn3t/fastapi-async-sqlalchemy +edit_uri: edit/main/docs/ + +copyright: Copyright © Eugene Shershen β€” MIT License + +theme: + name: material + language: en + icon: + logo: material/database-sync + repo: fontawesome/brands/github + favicon: assets/favicon.svg + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: teal + accent: deep orange + toggle: + icon: material/weather-sunny + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: teal + accent: amber + toggle: + icon: material/weather-night + name: Switch to system preference + font: + text: Inter + code: JetBrains Mono + features: + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.top + - navigation.tracking + - navigation.indexes + - navigation.footer + - toc.follow + - search.suggest + - search.highlight + - search.share + - content.code.copy + - content.code.annotate + - content.tabs.link + - content.tooltips + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/h0rn3t/fastapi-async-sqlalchemy + - icon: fontawesome/brands/python + link: https://pypi.org/project/fastapi-async-sqlalchemy/ + generator: false + +extra_css: + - assets/extra.css + +plugins: + - search + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - tables + - toc: + permalink: true + title: On this page + - pymdownx.betterem + - pymdownx.caret + - pymdownx.mark + - pymdownx.tilde + - pymdownx.keys + - pymdownx.details + - pymdownx.inlinehilite + - pymdownx.smartsymbols + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + +nav: + - Home: index.md + - Getting Started: getting-started.md + - User Guide: + - guide/index.md + - Sessions & Contexts: guide/sessions.md + - Engine Lifecycle: guide/engine-lifecycle.md + - Concurrent Queries: guide/concurrency.md + - Streaming Responses: guide/streaming.md + - Multiple Databases: guide/multi-database.md + - SQLAlchemy Events: guide/events.md + - Type Hints: guide/type-hints.md + - API Reference: api-reference.md + - FAQ & Troubleshooting: faq.md diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..19058a1 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,2 @@ +# Documentation toolchain β€” install with: pip install -r requirements-docs.txt +mkdocs-material>=9.5,<10 From c6272d20d3807d5ba5b365f918deb2a541a322d4 Mon Sep 17 00:00:00 2001 From: Eugene Shershen Date: Mon, 15 Jun 2026 00:28:37 +0300 Subject: [PATCH 4/6] update idna version constraint to >=3.15 in requirements.txt --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e514fb1..0c86149 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ coverage>=5.2.1 entrypoints==0.3 fastapi>=0.115 flake8==3.7.9 -idna==3.7 +idna>=3.15 importlib-metadata==1.5.0 isort==5.13.2 mccabe==0.6.1 From 5f3bfb988d7b6de4bbd4206a564cfb911ac091bb Mon Sep 17 00:00:00 2001 From: Eugene Shershen Date: Fri, 17 Jul 2026 00:04:53 +0300 Subject: [PATCH 5/6] Remove deprecated files and update project configuration in pyproject.toml. Adjust requirements.txt to streamline dependencies and enhance documentation for type hints in README.md and related files. --- .pyup.yml | 5 - README.md | 2 +- docs/api-reference.md | 7 +- docs/guide/type-hints.md | 5 +- examples/test_multisession_pool.py | 57 --- fastapi_async_sqlalchemy/__init__.py | 4 +- fastapi_async_sqlalchemy/_types.py | 3 - pyproject.toml | 43 +++ requirements.txt | 44 +-- setup.py | 48 --- tests/test_additional_coverage.py | 116 ------ tests/test_coverage_boost.py | 132 ------- tests/test_coverage_improvements.py | 287 --------------- tests/test_edge_cases_coverage.py | 118 ++----- tests/test_import_fallback_simulation.py | 200 ----------- tests/test_import_fallbacks.py | 58 --- tests/test_maximum_coverage.py | 430 ----------------------- tests/test_type_hints_compatibility.py | 23 -- 18 files changed, 93 insertions(+), 1489 deletions(-) delete mode 100644 .pyup.yml delete mode 100644 examples/test_multisession_pool.py delete mode 100644 setup.py delete mode 100644 tests/test_additional_coverage.py delete mode 100644 tests/test_coverage_boost.py delete mode 100644 tests/test_coverage_improvements.py delete mode 100644 tests/test_import_fallback_simulation.py delete mode 100644 tests/test_import_fallbacks.py delete mode 100644 tests/test_maximum_coverage.py diff --git a/.pyup.yml b/.pyup.yml deleted file mode 100644 index de9a616..0000000 --- a/.pyup.yml +++ /dev/null @@ -1,5 +0,0 @@ -# autogenerated pyup.io config file -# see https://pyup.io/docs/configuration/ for all available options - -schedule: '' -update: false diff --git a/README.md b/README.md index 829a1f1..cbb5f1b 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ transaction. #### Type hints for `db` -Use `DBSessionMeta` (or its alias `DBSessionType`) when you need to annotate +Use `DBSessionMeta` when you need to annotate a function or attribute that holds the `db` proxy: ```python diff --git a/docs/api-reference.md b/docs/api-reference.md index 98e51b6..75d613c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -8,7 +8,6 @@ from fastapi_async_sqlalchemy import ( db, create_middleware_and_session_proxy, DBSessionMeta, - DBSessionType, ) ``` @@ -157,11 +156,11 @@ See [Multiple Databases](guide/multi-database.md). --- -## `DBSessionMeta` / `DBSessionType` +## `DBSessionMeta` A structural `Protocol` (at type-check time) and the runtime metaclass of `db` -(at runtime). Use as an annotation for the `db` proxy. `DBSessionType` is an -alias of `DBSessionMeta`. See [Type Hints](guide/type-hints.md). +(at runtime). Use as an annotation for the `db` proxy. +See [Type Hints](guide/type-hints.md). ```python def get_db() -> DBSessionMeta: diff --git a/docs/guide/type-hints.md b/docs/guide/type-hints.md index 5ce1eba..c4a6ac8 100644 --- a/docs/guide/type-hints.md +++ b/docs/guide/type-hints.md @@ -6,7 +6,7 @@ proxy itself. ## Annotating `db` with `DBSessionMeta` -Use `DBSessionMeta` (or its alias `DBSessionType`) when you need to type a +Use `DBSessionMeta` when you need to type a function or attribute that holds the `db` proxy: ```python @@ -62,9 +62,6 @@ class DBSessionMeta(Protocol): ) -> AbstractAsyncContextManager[Any]: ... ``` -`DBSessionType` is an exported alias of `DBSessionMeta` β€” use whichever name -reads better in your codebase. - ## Dependency-injection style If you prefer passing `db` explicitly (e.g. for testability) rather than diff --git a/examples/test_multisession_pool.py b/examples/test_multisession_pool.py deleted file mode 100644 index 80f20c0..0000000 --- a/examples/test_multisession_pool.py +++ /dev/null @@ -1,57 +0,0 @@ -import asyncio - -import pytest -from sqlalchemy import text -from sqlalchemy.pool import AsyncAdaptedQueuePool - -from fastapi_async_sqlalchemy import create_middleware_and_session_proxy - -""" -Goal: Ensure that session for each task is closed immediately after task completion -to prevent session accumulation and connection pool exhaustion. -""" - -# Create separate middleware for testing -TestSQLAlchemyMiddleware, test_db = create_middleware_and_session_proxy() - - -async def execute_query(query_id: int): - """Execute query using session""" - result = await test_db.session.execute(text(f"SELECT {query_id} as id")) - # Simulate a long operation - await asyncio.sleep(0.5) # 0.5-second delay - return result.fetchone() - - -@pytest.mark.asyncio -async def test_multisession_with_limited_pool(): - """Test: 20 coroutines with multisession=True with a pool of 10 connections""" - - TestSQLAlchemyMiddleware( - app=None, - db_url="sqlite+aiosqlite:///test.db", - engine_args={ - "poolclass": AsyncAdaptedQueuePool, - "pool_size": 5, - "max_overflow": 0, - "echo": False, - }, - ) - - async with test_db(multi_sessions=True): - # Create 20 coroutines - tasks = [asyncio.create_task(execute_query(i)) for i in range(20)] - - # Execute all tasks in parallel - results = await asyncio.gather(*tasks) - - # Checks - assert len(results) == 20 - assert all(result is not None for result in results) - - print("βœ… Successfully executed 20 tasks") - print(f"πŸ“Š Results: {[r[0] for r in results]}") - - -if __name__ == "__main__": - asyncio.run(test_multisession_with_limited_pool()) diff --git a/fastapi_async_sqlalchemy/__init__.py b/fastapi_async_sqlalchemy/__init__.py index e6d95fa..94d8fd1 100644 --- a/fastapi_async_sqlalchemy/__init__.py +++ b/fastapi_async_sqlalchemy/__init__.py @@ -16,17 +16,15 @@ # created by ``create_middleware_and_session_proxy``) so ``isinstance(db, # DBSessionMeta)`` and ``type(db) is DBSessionMeta`` keep working as in v0.5. if TYPE_CHECKING: - from fastapi_async_sqlalchemy._types import DBSessionMeta, DBSessionType + from fastapi_async_sqlalchemy._types import DBSessionMeta else: DBSessionMeta = type(db) - DBSessionType = DBSessionMeta __all__ = [ "db", "SQLAlchemyMiddleware", "create_middleware_and_session_proxy", "DBSessionMeta", - "DBSessionType", ] __version__ = "0.8.0b1" diff --git a/fastapi_async_sqlalchemy/_types.py b/fastapi_async_sqlalchemy/_types.py index dba6a8f..b74ecce 100644 --- a/fastapi_async_sqlalchemy/_types.py +++ b/fastapi_async_sqlalchemy/_types.py @@ -58,6 +58,3 @@ def __call__( ) -> AbstractAsyncContextManager[Any]: """Open an explicit session context: ``async with db(): ...``.""" ... - - -DBSessionType = DBSessionMeta diff --git a/pyproject.toml b/pyproject.toml index 51f1169..2e9080a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,46 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "fastapi-async-sqlalchemy" +dynamic = ["version"] +description = "SQLAlchemy middleware for FastAPI" +readme = "README.md" +license = "MIT" +authors = [{ name = "Eugene Shershen", email = "h0rn3t.null@gmail.com" }] +requires-python = ">=3.12" +dependencies = ["starlette>=0.40", "SQLAlchemy>=2.0"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.urls] +Code = "https://github.com/h0rn3t/fastapi-async-sqlalchemy" +"Issue tracker" = "https://github.com/h0rn3t/fastapi-async-sqlalchemy/issues" + +[tool.setuptools] +packages = ["fastapi_async_sqlalchemy"] + +[tool.setuptools.package-data] +fastapi_async_sqlalchemy = ["py.typed"] + +[tool.setuptools.dynamic] +version = { attr = "fastapi_async_sqlalchemy.__version__" } + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/requirements.txt b/requirements.txt index 0c86149..1604c53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,42 +1,14 @@ -appdirs==1.4.3 -atomicwrites==1.3.0 -attrs>=19.3.0 -certifi>=2023.07.22 -chardet==3.0.4 -click>=8.1.3 -coverage>=5.2.1 -entrypoints==0.3 +# Runtime +SQLAlchemy>=2.0 +starlette>=0.40 + +# Test fastapi>=0.115 -flake8==3.7.9 -idna>=3.15 -importlib-metadata==1.5.0 -isort==5.13.2 -mccabe==0.6.1 -more-itertools==7.2.0 -packaging>=22.0 -pathspec>=0.9.0 -pluggy>=1.5.0 -pycodestyle==2.5.0 -pydantic>=2.7 -pyflakes==2.1.1 -pyparsing==2.4.2 +httpx>=0.20.0,<0.28.0 pytest>=8.3.0 +pytest-asyncio>=0.24.0 pytest-cov>=5.0.0 -PyYAML>=5.4 -regex>=2020.2.20 -requests>=2.22.0 -httpx>=0.20.0,<0.28.0 -six==1.12.0 -SQLAlchemy>=2.0 sqlmodel>=0.0.24 -asyncpg>=0.27.0 aiosqlite==0.20.0 -sqlparse>=0.5.4 -starlette>=0.40 -toml>=0.10.1 -urllib3>=1.25.9 -wcwidth==0.1.7 -zipp==3.19.1 -black==26.3.1 -pytest-asyncio>=0.24.0 +asyncpg>=0.27.0 greenlet>=3.2.4 diff --git a/setup.py b/setup.py deleted file mode 100644 index 894b18b..0000000 --- a/setup.py +++ /dev/null @@ -1,48 +0,0 @@ -import re -from pathlib import Path - -from setuptools import setup - -with open(Path("fastapi_async_sqlalchemy") / "__init__.py", encoding="utf-8") as fh: - version = re.search(r'__version__ = "(.*?)"', fh.read(), re.M).group(1) # type: ignore - -with open("README.md", encoding="utf-8") as fh: - long_description = fh.read() - -setup( - name="fastapi-async-sqlalchemy", - version=version, - url="https://github.com/h0rn3t/fastapi-async-sqlalchemy.git", - project_urls={ - "Code": "https://github.com/h0rn3t/fastapi-async-sqlalchemy", - "Issue tracker": "https://github.com/h0rn3t/fastapi-async-sqlalchemy/issues", - }, - license="MIT", - author="Eugene Shershen", - author_email="h0rn3t.null@gmail.com", - description="SQLAlchemy middleware for FastAPI", - long_description=long_description, - long_description_content_type="text/markdown", - packages=["fastapi_async_sqlalchemy"], - package_data={"fastapi_async_sqlalchemy": ["py.typed"]}, - zip_safe=False, - python_requires=">=3.12", - install_requires=["starlette>=0.40", "SQLAlchemy>=2.0"], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Web Environment", - "Framework :: AsyncIO", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Programming Language :: Python :: 3.15", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: Implementation :: CPython", - "Topic :: Internet :: WWW/HTTP :: HTTP Servers", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content", - "Topic :: Software Development :: Libraries :: Python Modules", - ], -) diff --git a/tests/test_additional_coverage.py b/tests/test_additional_coverage.py deleted file mode 100644 index d2c63ff..0000000 --- a/tests/test_additional_coverage.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -Additional tests to reach target coverage of 97.22% -""" - -import asyncio - -from fastapi import FastAPI - - -def test_commit_on_exit_parameter(): - """Test commit_on_exit parameter in middleware initialization""" - from sqlalchemy.ext.asyncio import create_async_engine - - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - SQLAlchemyMiddleware, db = create_middleware_and_session_proxy() - app = FastAPI() - - # Test commit_on_exit=True - custom_engine = create_async_engine("sqlite+aiosqlite://") - try: - middleware = SQLAlchemyMiddleware(app, custom_engine=custom_engine, commit_on_exit=True) - assert middleware.commit_on_exit is True - - # Test commit_on_exit=False (default) - middleware2 = SQLAlchemyMiddleware(app, custom_engine=custom_engine, commit_on_exit=False) - assert middleware2.commit_on_exit is False - finally: - asyncio.run(custom_engine.dispose()) - - -def test_exception_classes_simple(): - """Test exception classes are properly defined""" - from fastapi_async_sqlalchemy.exceptions import MissingSessionError, SessionNotInitialisedError - - # Test exception instantiation without parameters - missing_error = MissingSessionError() - assert isinstance(missing_error, Exception) - - init_error = SessionNotInitialisedError() - assert isinstance(init_error, Exception) - - -def test_middleware_properties(): - """Test middleware properties and methods""" - from fastapi import FastAPI - from sqlalchemy.ext.asyncio import create_async_engine - - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - SQLAlchemyMiddleware, db = create_middleware_and_session_proxy() - app = FastAPI() - - # Test middleware properties - custom_engine = create_async_engine("sqlite+aiosqlite://") - try: - middleware = SQLAlchemyMiddleware(app, custom_engine=custom_engine, commit_on_exit=True) - - assert hasattr(middleware, "commit_on_exit") - assert middleware.commit_on_exit is True - finally: - asyncio.run(custom_engine.dispose()) - - -def test_basic_imports(): - """Test basic imports and module structure""" - # Test main module imports - from fastapi_async_sqlalchemy import SQLAlchemyMiddleware, db - - assert SQLAlchemyMiddleware is not None - assert db is not None - - # Test exception imports - from fastapi_async_sqlalchemy.exceptions import MissingSessionError, SessionNotInitialisedError - - assert MissingSessionError is not None - assert SessionNotInitialisedError is not None - - # Test middleware module imports - from fastapi_async_sqlalchemy.middleware import ( - DefaultAsyncSession, - create_middleware_and_session_proxy, - ) - - assert create_middleware_and_session_proxy is not None - assert DefaultAsyncSession is not None - - -def test_middleware_factory_different_instances(): - """Test creating multiple middleware/db instances""" - from fastapi import FastAPI - from sqlalchemy.ext.asyncio import create_async_engine - - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - # Create first instance - SQLAlchemyMiddleware1, db1 = create_middleware_and_session_proxy() - - # Create second instance - SQLAlchemyMiddleware2, db2 = create_middleware_and_session_proxy() - - # They should be different instances - assert SQLAlchemyMiddleware1 is not SQLAlchemyMiddleware2 - assert db1 is not db2 - - # Test both instances work - app = FastAPI() - engine = create_async_engine("sqlite+aiosqlite://") - - try: - middleware1 = SQLAlchemyMiddleware1(app, custom_engine=engine) - middleware2 = SQLAlchemyMiddleware2(app, custom_engine=engine) - - assert middleware1 is not middleware2 - finally: - asyncio.run(engine.dispose()) diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py deleted file mode 100644 index 8d6baea..0000000 --- a/tests/test_coverage_boost.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Simple tests to boost coverage to target level -""" - -import asyncio -from unittest.mock import AsyncMock - -import pytest -from fastapi import FastAPI -from sqlalchemy.exc import SQLAlchemyError - - -def test_session_not_initialised_error(): - """Test SessionNotInitialisedError when accessing session without middleware""" - from fastapi_async_sqlalchemy.exceptions import SessionNotInitialisedError - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - # Create fresh middleware/db instances - no middleware initialization - SQLAlchemyMiddleware, db = create_middleware_and_session_proxy() - - # Should raise SessionNotInitialisedError (not MissingSessionError) when _Session is None - with pytest.raises(SessionNotInitialisedError): - _ = db.session - - -def test_missing_session_error(): - """Test MissingSessionError when session context is None""" - from fastapi_async_sqlalchemy.exceptions import MissingSessionError - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - SQLAlchemyMiddleware, db = create_middleware_and_session_proxy() - app = FastAPI() - SQLAlchemyMiddleware(app, db_url="sqlite+aiosqlite://") - - # Now _Session is initialized, but no active session context - # This should raise MissingSessionError - with pytest.raises(MissingSessionError): - _ = db.session - - -@pytest.mark.asyncio -async def test_rollback_on_commit_exception(): - """Test rollback is called when commit raises exception (lines 114-116)""" - # Create mock session that fails on commit - mock_session = AsyncMock() - mock_session.commit.side_effect = SQLAlchemyError("Commit failed!") - - # Create a simulated cleanup scenario - async def test_cleanup(): - # This simulates the cleanup function with commit_on_exit=True - try: - await mock_session.commit() - except Exception: - await mock_session.rollback() - raise - finally: - await mock_session.close() - - # Test that rollback is called when commit fails - with pytest.raises(SQLAlchemyError): - await test_cleanup() - - mock_session.rollback.assert_called_once() - mock_session.close.assert_called_once() - - -def test_import_fallbacks_work(): - """Test that import fallbacks are properly configured""" - # Test async_sessionmaker import (lines 16-19) - try: - from sqlalchemy.ext.asyncio import async_sessionmaker - - # If available, use it - assert async_sessionmaker is not None - except ImportError: # pragma: no cover - # Lines 18-19 would execute if async_sessionmaker not available - from sqlalchemy.orm import sessionmaker as async_sessionmaker - - assert async_sessionmaker is not None - - # Test DefaultAsyncSession import (lines 22-27) - from sqlalchemy.ext.asyncio import AsyncSession - - from fastapi_async_sqlalchemy.middleware import DefaultAsyncSession - - # Should be either SQLModel's AsyncSession or regular AsyncSession - assert issubclass(DefaultAsyncSession, AsyncSession) - - -def test_db_url_validation_with_none(): - """Test ValueError when db_url is explicitly None (line 58)""" - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - SQLAlchemyMiddleware, db = create_middleware_and_session_proxy() - app = FastAPI() - - # Force the condition on line 58: db_url is None when custom_engine is not provided - with pytest.raises(ValueError, match="You need to pass a db_url or a custom_engine parameter"): - # This hits line 55 first, but let's also test a more specific case - SQLAlchemyMiddleware(app, db_url=None, custom_engine=None) - - -# Skipping the problematic test for now - - -def test_skipped_tests_make_coverage(): - """Extra assertions to boost coverage a bit""" - # Test basic imports work - from fastapi_async_sqlalchemy import SQLAlchemyMiddleware, db - - assert SQLAlchemyMiddleware is not None - assert db is not None - - from fastapi_async_sqlalchemy.exceptions import MissingSessionError, SessionNotInitialisedError - - assert MissingSessionError is not None - assert SessionNotInitialisedError is not None - - # Test middleware with custom engine path - from sqlalchemy.ext.asyncio import create_async_engine - - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - SQLAlchemyMiddleware, db_fresh = create_middleware_and_session_proxy() - app = FastAPI() - - custom_engine = create_async_engine("sqlite+aiosqlite://") - try: - middleware = SQLAlchemyMiddleware(app, custom_engine=custom_engine) - assert middleware.commit_on_exit is False # Default value - finally: - asyncio.run(custom_engine.dispose()) diff --git a/tests/test_coverage_improvements.py b/tests/test_coverage_improvements.py deleted file mode 100644 index b94f755..0000000 --- a/tests/test_coverage_improvements.py +++ /dev/null @@ -1,287 +0,0 @@ -""" -Tests to improve code coverage for edge cases and fallback imports. -""" - -import asyncio -from unittest.mock import MagicMock, patch - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient -from sqlalchemy.sql import text - -from fastapi_async_sqlalchemy import SQLAlchemyMiddleware, db - - -@pytest.mark.asyncio -async def test_cleanup_callback_with_closed_loop(): - """Test cleanup callback when event loop is closed.""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_closed_loop") - async def test_closed_loop(): - async with db(multi_sessions=True): - # Create a task that will trigger cleanup - async def child_task(): - session = db.session - await session.execute(text("SELECT 1")) - return "done" - - task = asyncio.create_task(child_task()) - result = await task - - return {"result": result} - - with TestClient(app) as client: - response = client.get("/test_closed_loop") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_cleanup_callback_runtime_error(): - """Test cleanup callback when get_running_loop raises RuntimeError.""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_runtime_error") - async def test_runtime_error(): - async with db(multi_sessions=True): - - async def child_task(): - session = db.session - await session.execute(text("SELECT 42")) - return "ok" - - task = asyncio.create_task(child_task()) - result = await task - - # Give time for cleanup callbacks to execute - await asyncio.sleep(0.1) - return {"result": result} - - with TestClient(app) as client: - response = client.get("/test_runtime_error") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_multiple_child_tasks_cleanup(): - """Test that multiple child tasks all get cleanup callbacks.""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_multiple_cleanup") - async def test_multiple_cleanup(): - results = [] - async with db(multi_sessions=True): - - async def child_task(n): - session = db.session - result = await session.execute(text(f"SELECT {n}")) - return result.scalar() - - tasks = [asyncio.create_task(child_task(i)) for i in range(5)] - results = await asyncio.gather(*tasks) - - return {"results": results} - - with TestClient(app) as client: - response = client.get("/test_multiple_cleanup") - assert response.status_code == 200 - assert len(response.json()["results"]) == 5 - - -def test_import_coverage_markers(): - """Test that import fallback code paths are marked for coverage.""" - # This test ensures that import fallback blocks are properly marked - # even if they can't be executed in the current environment - - # The actual imports happen at module load time, so we can't test them - # directly without manipulating sys.modules before import. - # Instead, we verify the code exists and is syntactically correct. - - import fastapi_async_sqlalchemy.middleware as middleware_module - - # Verify that DefaultAsyncSession is set - assert hasattr(middleware_module, "DefaultAsyncSession") - - # Check if we're using SQLModel or plain AsyncSession - from sqlalchemy.ext.asyncio import AsyncSession - - assert issubclass(middleware_module.DefaultAsyncSession, AsyncSession) - - -@pytest.mark.asyncio -async def test_current_task_none_scenario(): - """ - Test scenario where asyncio.current_task() might return None. - - Note: This is extremely rare in practice and hard to reproduce, - as current_task() only returns None when called outside an event loop. - The middleware already requires an event loop, so this edge case - is primarily for completeness. - """ - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_task_context") - async def test_task_context(): - # Verify we're in a task context - task = asyncio.current_task() - assert task is not None, "Should have current task in request context" - - async with db(multi_sessions=True): - # Access session within task context - session = db.session - await session.execute(text("SELECT 1")) - - return {"success": True} - - with TestClient(app) as client: - response = client.get("/test_task_context") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_edge_case_loop_closing_during_cleanup(): - """Test edge case where loop closes during cleanup callback setup.""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_loop_edge") - async def test_loop_edge(): - async with db(multi_sessions=True, commit_on_exit=True): - # Create multiple tasks that will all need cleanup - async def quick_task(n): - s = db.session - await s.execute(text(f"SELECT {n}")) - - tasks = [asyncio.create_task(quick_task(i)) for i in range(3)] - await asyncio.gather(*tasks) - - return {"done": True} - - with TestClient(app) as client: - response = client.get("/test_loop_edge") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_current_task_none_with_mock(): - """ - Test behavior when current_task() returns None (sync context fallback). - - After the backward compatibility fix, this now creates a session for sync context - instead of raising RuntimeError. - """ - - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_none_task") - async def test_none_task(): - # Temporarily mock current_task to return None - with patch("asyncio.current_task", return_value=None): - async with db(multi_sessions=True): - # After backward compatibility fix, this should work - # by falling back to sync context session - session = db.session - if session is not None: - return {"success": True, "has_session": True} - return {"error": "Session is None"} - - with TestClient(app) as client: - response = client.get("/test_none_task") - assert response.status_code == 200 - assert response.json()["success"] is True - assert response.json()["has_session"] is True - - -@pytest.mark.asyncio -async def test_cleanup_callback_with_mocked_closed_loop(): - """ - Test cleanup callback behavior when loop.is_closed() returns True. - - This is a direct test of the cleanup_callback function to ensure - it handles the closed loop case properly (lines 109-110). - """ - - # We need to test the cleanup callback directly - # First, let's access the session property to trigger session creation with cleanup - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - callback_executed = [] - - @app.get("/test_mock_closed") - async def test_mock_closed(): - async with db(multi_sessions=True): - # Patch get_running_loop inside the cleanup callback - - async def child_with_mock(): - session = db.session - await session.execute(text("SELECT 1")) - - # After this task finishes, the cleanup callback will be called - # We want to mock get_running_loop at that point - return "done" - - # Patch asyncio.get_running_loop to return a closed loop - def mock_get_running_loop_closed(): - loop = MagicMock() - loop.is_closed.return_value = True - callback_executed.append("closed_loop_path") - return loop - - with patch("asyncio.get_running_loop", side_effect=mock_get_running_loop_closed): - task = asyncio.create_task(child_with_mock()) - await task - # Task is done, cleanup callback will execute with mocked get_running_loop - - return {"done": True} - - with TestClient(app) as client: - with pytest.warns(UserWarning, match="No running event loop during cleanup"): - response = client.get("/test_mock_closed") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_cleanup_callback_with_runtime_error(): - """ - Test cleanup callback when get_running_loop() raises RuntimeError. - - This tests the except RuntimeError branch (lines 111-112). - """ - - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - callback_executed = [] - - @app.get("/test_runtime_error") - async def test_runtime_error(): - async with db(multi_sessions=True): - - async def child_with_runtime_error(): - session = db.session - await session.execute(text("SELECT 1")) - return "done" - - # Patch get_running_loop to raise RuntimeError - def mock_get_running_loop_error(): - callback_executed.append("runtime_error_path") - raise RuntimeError("No running event loop") - - with patch("asyncio.get_running_loop", side_effect=mock_get_running_loop_error): - task = asyncio.create_task(child_with_runtime_error()) - await task - # Cleanup callback will execute and hit the RuntimeError path - - return {"done": True} - - with TestClient(app) as client: - with pytest.warns(UserWarning, match="No running event loop during cleanup"): - response = client.get("/test_runtime_error") - assert response.status_code == 200 diff --git a/tests/test_edge_cases_coverage.py b/tests/test_edge_cases_coverage.py index 4a9155b..fa09cc7 100644 --- a/tests/test_edge_cases_coverage.py +++ b/tests/test_edge_cases_coverage.py @@ -4,16 +4,15 @@ """ import asyncio +from unittest.mock import patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.sql import text from fastapi_async_sqlalchemy import SQLAlchemyMiddleware, db -from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy @pytest.mark.asyncio @@ -160,86 +159,6 @@ async def tracking_rollback(): assert response.status_code == 500 or response.status_code == 200 -@pytest.mark.asyncio -async def test_session_created_without_tracking_warning(): - """Test warning when session is created without tracking (lines 117-122)""" - # This is tricky to test as it requires accessing session property - # outside of proper context setup - - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - SQLAlchemyMiddleware_local, db_local = create_middleware_and_session_proxy() - - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware_local, db_url="sqlite+aiosqlite:///:memory:") - - with TestClient(app): - pass - - # This test verifies the warning path exists - # In normal usage, the tracking set is always created in __aenter__ - # so this warning shouldn't occur in production - - -def test_custom_engine_branch(): - """Test that custom_engine branch is exercised (line 61)""" - SQLAlchemyMiddleware_local, db_local = create_middleware_and_session_proxy() - - app = FastAPI() - - # Create custom engine - custom_engine = create_async_engine("sqlite+aiosqlite:///:memory:") - - try: - # This should use the else branch on line 61 - middleware = SQLAlchemyMiddleware_local( - app, - custom_engine=custom_engine, - commit_on_exit=False, - ) - - assert middleware is not None - assert middleware.commit_on_exit is False - finally: - asyncio.run(custom_engine.dispose()) - - -@pytest.mark.asyncio -async def test_import_fallback_coverage(): - """ - Test to document import fallback behavior (lines 18-19, 26-27) - These lines are only executed in environments without SQLAlchemy 2.0+ - or without SQLModel installed - """ - # Line 18-19: async_sessionmaker fallback - # This is only needed for SQLAlchemy < 2.0 - # In modern SQLAlchemy (2.0+), async_sessionmaker exists - - try: - from sqlalchemy.ext.asyncio import async_sessionmaker - - assert async_sessionmaker is not None - # If we're here, lines 18-19 won't execute - except ImportError: # pragma: no cover - # In older SQLAlchemy, this would execute - from sqlalchemy.orm import sessionmaker - - assert sessionmaker is not None - - # Lines 26-27: SQLModel fallback - # These lines execute when SQLModel is NOT installed - try: - from sqlmodel.ext.asyncio.session import AsyncSession as SQLModelAsyncSession - - # If SQLModel is available, line 27 won't execute - assert SQLModelAsyncSession is not None - except ImportError: - # Line 27 would execute if SQLModel not available - from sqlalchemy.ext.asyncio import AsyncSession - - assert AsyncSession is not None - - @pytest.mark.asyncio async def test_multi_session_cleanup_all_paths(): """Comprehensive test for all multi-session cleanup paths""" @@ -309,3 +228,38 @@ async def test_single_exception(): with TestClient(app) as client: response = client.get("/test_single_exception") assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_cleanup_callback_without_running_loop_warns(): + """Cleanup callback must warn (not crash) when no event loop is available. + + Covers the `except RuntimeError` fallback when capturing the loop at + session creation time and the "No running event loop" warning in the + task-done cleanup callback. + """ + app = FastAPI() + app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") + + @app.get("/test_no_loop") + async def test_no_loop(): + async with db(multi_sessions=True): + + async def child_task(): + session = db.session + await session.execute(text("SELECT 1")) + return "done" + + with patch( + "asyncio.get_running_loop", + side_effect=RuntimeError("No running event loop"), + ): + task = asyncio.create_task(child_task()) + await task + + return {"done": True} + + with TestClient(app) as client: + with pytest.warns(UserWarning, match="No running event loop during cleanup"): + response = client.get("/test_no_loop") + assert response.status_code == 200 diff --git a/tests/test_import_fallback_simulation.py b/tests/test_import_fallback_simulation.py deleted file mode 100644 index 848fe62..0000000 --- a/tests/test_import_fallback_simulation.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -Tests to verify import fallback behavior -These tests document the behavior of lines 18-19 and 26-27 -which only execute in specific import scenarios -""" - -import asyncio - -import pytest - - -def test_async_sessionmaker_import_documentation(): - """ - Document async_sessionmaker import fallback (lines 18-19) - - Lines 18-19 in middleware.py: - except ImportError: - from sqlalchemy.orm import sessionmaker as async_sessionmaker - - These lines only execute when SQLAlchemy doesn't have async_sessionmaker, - which would be SQLAlchemy < 2.0. Since our project requires SQLAlchemy 1.4.19+, - this fallback ensures compatibility. - - In modern SQLAlchemy (2.0+), async_sessionmaker exists, so line 18-19 won't run. - """ - # Verify that async_sessionmaker is available in current environment - from sqlalchemy.ext.asyncio import async_sessionmaker - - assert async_sessionmaker is not None - assert callable(async_sessionmaker) - - -def test_sqlmodel_import_documentation(): - """ - Document SQLModel AsyncSession import fallback (lines 26-27) - - Lines 26-27 in middleware.py: - except ImportError: - DefaultAsyncSession: Type[AsyncSession] = AsyncSession - - Line 27 only executes when SQLModel is NOT installed. - Since our test environment has SQLModel, line 27 won't be covered. - - This test documents that the fallback exists for environments without SQLModel. - """ - # Check if SQLModel is available - try: - from sqlmodel.ext.asyncio.session import AsyncSession as SQLModelAsyncSession - - # SQLModel is available, so line 27 won't execute - assert SQLModelAsyncSession is not None - - # Verify that our middleware uses SQLModel's AsyncSession - from fastapi_async_sqlalchemy.middleware import DefaultAsyncSession - - assert DefaultAsyncSession == SQLModelAsyncSession - except ImportError: - # If SQLModel is not available, line 27 would execute - from sqlalchemy.ext.asyncio import AsyncSession - - from fastapi_async_sqlalchemy.middleware import DefaultAsyncSession - - assert DefaultAsyncSession == AsyncSession - - -def test_custom_engine_else_branch_execution(): - """ - Test to verify custom_engine else branch (line 61) - - The middleware has this structure: - if not custom_engine: - engine = create_async_engine(db_url, **engine_args) - else: - engine = custom_engine # Line 61 - - We need to ensure this branch is actually executed. - """ - from fastapi import FastAPI - from sqlalchemy.ext.asyncio import create_async_engine - - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - SQLAlchemyMiddleware_local, db_local = create_middleware_and_session_proxy() - - app = FastAPI() - - # Create a custom engine with specific settings - custom_engine = create_async_engine( - "sqlite+aiosqlite:///:memory:", echo=False, pool_pre_ping=True - ) - - try: - # Initialize middleware with custom_engine - # This should execute line 61: engine = custom_engine - middleware = SQLAlchemyMiddleware_local(app, custom_engine=custom_engine) - - # Verify middleware was created - assert middleware is not None - finally: - asyncio.run(custom_engine.dispose()) - - -def test_session_tracking_warning_scenario(): - """ - Test the warning scenario on line 117 - - This warning occurs when: - - multi_sessions mode is active - - A session is created (via db.session property) - - But _tracked_sessions.get() returns None - - This should not happen in normal usage since __aenter__ sets up tracking, - but the warning is there as a safety check. - """ - - # This tests that the code path exists - # In practice, the tracking set is always created in __aenter__ - # before any session can be accessed - - # The warning would appear if somehow the tracking context var was not set - # when accessing db.session in multi_sessions mode - - # Since this requires internal manipulation of context vars, - # we document it here rather than trying to force the condition - - -@pytest.mark.asyncio -async def test_simulated_import_fallback_for_older_sqlalchemy(): - """ - Simulation test showing what would happen with older SQLAlchemy - - This test documents the behavior but cannot force the import - without breaking the current environment. - """ - # In an environment with SQLAlchemy < 2.0: - # - Line 17 would fail to import async_sessionmaker - # - Lines 18-19 would execute instead - # - The middleware would use sessionmaker from sqlalchemy.orm - - # Since we're testing with SQLAlchemy 2.0+, we just verify - # that the modern import works - from sqlalchemy.ext.asyncio import async_sessionmaker - - assert async_sessionmaker is not None - - -@pytest.mark.asyncio -async def test_verify_all_middleware_branches_tested(): - """ - Meta-test to verify we've covered all major code paths - """ - from fastapi import FastAPI - from fastapi.testclient import TestClient - from sqlalchemy.ext.asyncio import create_async_engine - - from fastapi_async_sqlalchemy import SQLAlchemyMiddleware - - # Test 1: db_url path (line 59: engine = create_async_engine) - app1 = FastAPI() - app1.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite://") - with TestClient(app1) as client1: - assert client1 is not None - - # Test 2: custom_engine path (line 61: engine = custom_engine) - from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - SQLAlchemyMiddleware2, _ = create_middleware_and_session_proxy() - app2 = FastAPI() - custom_engine = create_async_engine("sqlite+aiosqlite://") - app2.add_middleware(SQLAlchemyMiddleware2, custom_engine=custom_engine) - try: - with TestClient(app2) as client2: - assert client2 is not None - finally: - await custom_engine.dispose() - - -def test_coverage_report_explanation(): - """ - Documentation of remaining uncovered lines and why - - Uncovered Lines: - - Lines 18-19: Import fallback for SQLAlchemy < 2.0 - Cannot be covered when running tests with SQLAlchemy 2.0+ - - - Lines 26-27: Import fallback when SQLModel not installed - Cannot be covered when running tests with SQLModel installed - - - Line 61: else branch for custom_engine - Should be covered by custom_engine tests - - - Line 117: Warning for missing session tracking - Defensive code that shouldn't occur in normal usage - - These lines provide important fallback and safety behavior - but are difficult or impossible to cover in a test environment - that has all dependencies installed. - """ - # This test passes to document the coverage situation - assert True diff --git a/tests/test_import_fallbacks.py b/tests/test_import_fallbacks.py deleted file mode 100644 index 7bb1838..0000000 --- a/tests/test_import_fallbacks.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Tests for import fallback scenarios. - -These tests verify that the code handles missing optional dependencies gracefully. -""" - - -def test_sqlmodel_not_installed_fallback(): - """Test fallback when SQLModel is not installed.""" - import inspect - - import fastapi_async_sqlalchemy.middleware as mod - - # Verify the fallback code structure exists - source = inspect.getsource(mod) - assert "try:" in source - assert "from sqlmodel.ext.asyncio.session import AsyncSession as SQLModelAsyncSession" in source - assert "except ImportError:" in source - assert "DefaultAsyncSession: type[AsyncSession] = AsyncSession" in source - - -def test_default_async_session_type(): - """Test that DefaultAsyncSession is properly set.""" - from sqlalchemy.ext.asyncio import AsyncSession - - from fastapi_async_sqlalchemy.middleware import DefaultAsyncSession - - # Should be either SQLModel's AsyncSession or SQLAlchemy's AsyncSession - assert issubclass(DefaultAsyncSession, AsyncSession) - - # Verify it's a valid session class - assert hasattr(DefaultAsyncSession, "__init__") - - -def test_coverage_pragmas_not_needed(): - """ - Verify that fallback imports don't need pragma: no cover. - - We achieve this by having tests that at least verify the code structure, - even if we can't execute both paths in a single test run. - """ - import inspect - - import fastapi_async_sqlalchemy.middleware as mod - - source = inspect.getsource(mod) - - # Ensure no pragma: no cover on import blocks - # (These should be covered by structural tests) - lines = source.split("\n") - for i, line in enumerate(lines): - if "pragma: no cover" in line: - # Check if it's in an import block - if i > 0 and "import" in lines[i - 1]: - raise AssertionError( - f"Import fallback at line {i} should not have pragma: no cover. " - "Use structural tests instead." - ) diff --git a/tests/test_maximum_coverage.py b/tests/test_maximum_coverage.py deleted file mode 100644 index 8391d27..0000000 --- a/tests/test_maximum_coverage.py +++ /dev/null @@ -1,430 +0,0 @@ -""" -Comprehensive tests to achieve maximum code coverage -Focuses on uncovered lines in middleware.py -""" - -import asyncio - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from sqlalchemy.sql import text - -from fastapi_async_sqlalchemy import SQLAlchemyMiddleware, db -from fastapi_async_sqlalchemy.exceptions import MissingSessionError, SessionNotInitialisedError -from fastapi_async_sqlalchemy.middleware import create_middleware_and_session_proxy - - -@pytest.mark.asyncio -async def test_multi_session_cleanup_with_commit_exception(): - """Commit failure in multi-session cleanup must fail the request.""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite://") - - @app.get("/test_commit_failure") - async def test_commit_failure(): - async with db(multi_sessions=True, commit_on_exit=True): - # Access session to trigger creation - session = db.session - - # Mock the commit to raise an exception - async def failing_commit(): - raise SQLAlchemyError("Simulated commit failure") - - session.commit = failing_commit - - # Store original rollback to verify it was called - rollback_called = False - original_rollback = session.rollback - - async def tracking_rollback(): - nonlocal rollback_called - rollback_called = True - await original_rollback() - - session.rollback = tracking_rollback - - return {"session_id": id(session)} - - with TestClient(app, raise_server_exceptions=False) as client: - response = client.get("/test_commit_failure") - assert response.status_code == 500 - - -@pytest.mark.asyncio -async def test_multi_session_commit_on_exit_success(): - """Test successful commit in multi-session mode with commit_on_exit=True""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - commit_was_called = False - - @app.get("/test_commit_success") - async def test_commit_success(): - nonlocal commit_was_called - async with db(multi_sessions=True, commit_on_exit=True): - session = db.session - - # Track if commit is called - original_commit = session.commit - - async def tracking_commit(): - nonlocal commit_was_called - commit_was_called = True - await original_commit() - - session.commit = tracking_commit - - # Execute a simple query - await session.execute(text("SELECT 1")) - - return {"status": "ok"} - - with TestClient(app) as client: - response = client.get("/test_commit_success") - assert response.status_code == 200 - - # Give cleanup time to run - await asyncio.sleep(0.1) - - -@pytest.mark.asyncio -async def test_multi_session_multiple_tasks_with_cleanup(): - """Test multi-session mode with multiple concurrent tasks and verify cleanup""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - session_ids = [] - - @app.get("/test_multi_cleanup") - async def test_multi_cleanup(): - async with db(multi_sessions=True, commit_on_exit=True): - - async def execute_with_session(value: int): - session = db.session - session_ids.append(id(session)) - result = await session.execute(text(f"SELECT {value}")) - return result.scalar() - - # Create multiple tasks - tasks = [asyncio.create_task(execute_with_session(i)) for i in range(5)] - - results = await asyncio.gather(*tasks) - return {"results": results, "session_count": len(set(session_ids))} - - with TestClient(app) as client: - response = client.get("/test_multi_cleanup") - assert response.status_code == 200 - - -def test_import_fallback_async_sessionmaker(): - """Test import fallback for async_sessionmaker (lines 18-19)""" - # This test verifies the import works - # The fallback is only used on older SQLAlchemy versions - try: - from sqlalchemy.ext.asyncio import async_sessionmaker - - assert async_sessionmaker is not None - except ImportError: # pragma: no cover - # If async_sessionmaker doesn't exist, the fallback should work - from sqlalchemy.orm import sessionmaker - - assert sessionmaker is not None - - -def test_import_fallback_sqlmodel(): - """Test import fallback for SQLModel AsyncSession (lines 26-27)""" - # Test that DefaultAsyncSession is properly set - from fastapi_async_sqlalchemy.middleware import DefaultAsyncSession - - # It should be a subclass of AsyncSession regardless of SQLModel availability - assert issubclass(DefaultAsyncSession, AsyncSession) - - # Check if SQLModel is available - try: - from sqlmodel.ext.asyncio.session import AsyncSession as SQLModelAsyncSession - - # If SQLModel is available, DefaultAsyncSession should be SQLModelAsyncSession - assert DefaultAsyncSession == SQLModelAsyncSession - except ImportError: - # If SQLModel is not available, DefaultAsyncSession should be regular AsyncSession - assert DefaultAsyncSession == AsyncSession - - -def test_db_url_none_validation(): - """Test line 58: db_url validation when it's explicitly None""" - # This is actually unreachable code due to line 54-55 check - # But we can verify the validation logic - SQLAlchemyMiddleware_local, _ = create_middleware_and_session_proxy() - - app = FastAPI() - - # This should raise ValueError at line 55 - with pytest.raises(ValueError, match="You need to pass a db_url or a custom_engine parameter"): - SQLAlchemyMiddleware_local(app, db_url=None, custom_engine=None) - - -def test_custom_engine_path(): - """Test middleware initialization with custom_engine (line 61)""" - SQLAlchemyMiddleware_local, db_local = create_middleware_and_session_proxy() - - app = FastAPI() - custom_engine = create_async_engine("sqlite+aiosqlite:///:memory:") - - try: - # Initialize with custom engine - middleware = SQLAlchemyMiddleware_local(app, custom_engine=custom_engine) - assert middleware.commit_on_exit is False - finally: - asyncio.run(custom_engine.dispose()) - - # Verify it doesn't require db_url - # This covers the else branch on line 61 - - -@pytest.mark.asyncio -async def test_session_outside_middleware_context(): - """Test accessing session outside middleware raises MissingSessionError""" - # Create a fresh middleware instance - SQLAlchemyMiddleware_local, db_local = create_middleware_and_session_proxy() - - app = FastAPI() - SQLAlchemyMiddleware_local(app, db_url="sqlite+aiosqlite://") - - # Try to access session outside of request context - with pytest.raises(MissingSessionError): - _ = db_local.session - - -@pytest.mark.asyncio -async def test_multi_session_mode_context_vars(): - """Test that multi_session mode properly sets and resets context variables""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_context_vars") - async def test_context_vars(): - async with db(multi_sessions=True, commit_on_exit=True): - session1 = db.session - session2 = db.session - - assert session1 is not None - assert session2 is not None - assert session1 is session2 - - return {"status": "ok"} - - with TestClient(app) as client: - response = client.get("/test_context_vars") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_regular_session_context_exit_with_exception(): - """Test that regular session mode rolls back on exception (line 162)""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_rollback") - async def test_rollback(): - try: - async with db(): - session = db.session - await session.execute(text("SELECT 1")) - # Simulate an error - raise ValueError("Test exception") - except ValueError: - pass - - return {"status": "rolled_back"} - - with TestClient(app) as client: - response = client.get("/test_rollback") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_regular_session_commit_on_exit(): - """Test regular session mode with commit_on_exit=True (line 164)""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_commit") - async def test_commit(): - async with db(commit_on_exit=True): - session = db.session - await session.execute(text("SELECT 1")) - # No exception, should commit - - return {"status": "committed"} - - with TestClient(app) as client: - response = client.get("/test_commit") - assert response.status_code == 200 - - -def test_middleware_commit_on_exit_parameter(): - """Test SQLAlchemyMiddleware with commit_on_exit parameter""" - SQLAlchemyMiddleware_local, db_local = create_middleware_and_session_proxy() - - app = FastAPI() - - # Test with commit_on_exit=True - middleware = SQLAlchemyMiddleware_local(app, db_url="sqlite+aiosqlite://", commit_on_exit=True) - assert middleware.commit_on_exit is True - - # Test with commit_on_exit=False on a separate proxy to avoid rebinding the singleton. - SecondMiddleware, _ = create_middleware_and_session_proxy() - middleware2 = SecondMiddleware(app, db_url="sqlite+aiosqlite://", commit_on_exit=False) - assert middleware2.commit_on_exit is False - - -def test_engine_args_and_session_args(): - """Test middleware initialization with engine_args and session_args""" - SQLAlchemyMiddleware_local, db_local = create_middleware_and_session_proxy() - - app = FastAPI() - - # Use valid engine args for sqlite - engine_args = {"echo": True} - # Don't pass expire_on_commit since it's already set to False in middleware - session_args = {"autoflush": False} - - middleware = SQLAlchemyMiddleware_local( - app, db_url="sqlite+aiosqlite://", engine_args=engine_args, session_args=session_args - ) - - assert middleware is not None - - -@pytest.mark.asyncio -async def test_session_not_initialised_in_context(): - """Test SessionNotInitialisedError in __aenter__ (line 145)""" - # Create a fresh instance without initializing - SQLAlchemyMiddleware_local, db_local = create_middleware_and_session_proxy() - - # Try to use context without initializing middleware - with pytest.raises(SessionNotInitialisedError): - async with db_local(): - pass - - -@pytest.mark.asyncio -async def test_multi_session_token_reset(): - """Test that multi_session tokens are properly reset (lines 156-157)""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_token_reset") - async def test_token_reset(): - # Use multi_sessions context - async with db(multi_sessions=True): - session = db.session - await session.execute(text("SELECT 1")) - - # After exiting, should not be in multi_sessions mode - # Verify by trying to access session (should raise MissingSessionError) - return {"status": "ok"} - - with TestClient(app) as client: - response = client.get("/test_token_reset") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_session_args_parameter(): - """Test DBSession with session_args parameter""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_session_args") - async def test_session_args(): - # Use session_args in context - session_args = {"expire_on_commit": False} - async with db(session_args=session_args): - session = db.session - result = await session.execute(text("SELECT 42")) - value = result.scalar() - - return {"value": value} - - with TestClient(app) as client: - response = client.get("/test_session_args") - assert response.status_code == 200 - assert response.json()["value"] == 42 - - -@pytest.mark.asyncio -async def test_multi_session_without_commit_on_exit(): - """Test multi_session mode with commit_on_exit=False (default)""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_no_commit") - async def test_no_commit(): - async with db(multi_sessions=True, commit_on_exit=False): - session = db.session - await session.execute(text("SELECT 1")) - # Should not commit on cleanup - - return {"status": "no_commit"} - - with TestClient(app) as client: - response = client.get("/test_no_commit") - assert response.status_code == 200 - - -@pytest.mark.asyncio -async def test_task_done_callback_cleanup(): - """Test that cleanup is added as task done callback (line 122)""" - app = FastAPI() - app.add_middleware(SQLAlchemyMiddleware, db_url="sqlite+aiosqlite:///:memory:") - - @app.get("/test_callback") - async def test_callback(): - async with db(multi_sessions=True, commit_on_exit=True): - - async def task_function(): - session = db.session - await session.execute(text("SELECT 1")) - return "done" - - # Create a task that will have cleanup callback - task = asyncio.create_task(task_function()) - result = await task - - return {"result": result} - - with TestClient(app) as client: - response = client.get("/test_callback") - assert response.status_code == 200 - - # Give cleanup time to execute - await asyncio.sleep(0.1) - - -def test_all_exception_classes(): - """Test all custom exception classes""" - from fastapi_async_sqlalchemy.exceptions import ( - MissingSessionError, - SessionNotInitialisedError, - ) - - # Test SessionNotInitialisedError - exc1 = SessionNotInitialisedError() - assert "not initialised" in str(exc1).lower() - assert isinstance(exc1, Exception) - - # Test MissingSessionError - exc2 = MissingSessionError() - assert "no session found" in str(exc2).lower() - assert isinstance(exc2, Exception) - - # Test that they can be raised - with pytest.raises(SessionNotInitialisedError): - raise SessionNotInitialisedError() - - with pytest.raises(MissingSessionError): - raise MissingSessionError() diff --git a/tests/test_type_hints_compatibility.py b/tests/test_type_hints_compatibility.py index baad1cc..758e392 100644 --- a/tests/test_type_hints_compatibility.py +++ b/tests/test_type_hints_compatibility.py @@ -7,7 +7,6 @@ from fastapi_async_sqlalchemy import ( DBSessionMeta, - DBSessionType, db, ) @@ -18,17 +17,6 @@ def test_dbsessionmeta_is_exported(): assert isinstance(DBSessionMeta, type) -def test_dbsessiontype_is_exported(): - """Test that DBSessionType is available for import (alternative name)""" - assert DBSessionType is not None - assert isinstance(DBSessionType, type) - - -def test_dbsessionmeta_equals_dbsessiontype(): - """Test that both names refer to the same type""" - assert DBSessionMeta is DBSessionType - - def test_type_of_db_is_dbsessionmeta(): """Test that db instance has DBSessionMeta as its type""" assert type(db) is DBSessionMeta @@ -48,17 +36,6 @@ def get_db() -> DBSessionMeta: assert type(result) is DBSessionMeta -def test_alternative_type_hint_name(): - """Test that DBSessionType works as type hint""" - - def get_db_session() -> DBSessionType: - return db - - result = get_db_session() - assert result is db - assert type(result) is DBSessionType - - def test_backwards_compatibility_with_old_code(): """ Test backwards compatibility with code from v0.5 From ff514df0cda9c00a4f997fa087c00bf448178313 Mon Sep 17 00:00:00 2001 From: Eugene Shershen Date: Fri, 17 Jul 2026 00:12:20 +0300 Subject: [PATCH 6/6] fix ruff --- tests/test_concurrent_queries_postgres.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_concurrent_queries_postgres.py b/tests/test_concurrent_queries_postgres.py index b4c9ed3..99abb76 100644 --- a/tests/test_concurrent_queries_postgres.py +++ b/tests/test_concurrent_queries_postgres.py @@ -47,8 +47,7 @@ async def setup_table(app, db, SQLAlchemyMiddleware, table_name): ) await db.session.execute( text( - f"INSERT INTO {table_name} (name) " - "SELECT 'row_' || g FROM generate_series(1, 50) g" + f"INSERT INTO {table_name} (name) SELECT 'row_' || g FROM generate_series(1, 50) g" ) ) try: