fastapi-toolsets 4.1.3__tar.gz → 5.0.0b2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/PKG-INFO +1 -1
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/pyproject.toml +1 -1
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/__init__.py +6 -3
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/fixtures.py +20 -17
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/config.py +32 -21
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/utils.py +2 -1
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/factory.py +5 -5
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/__init__.py +18 -0
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/core.py +323 -0
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/locks.py +185 -0
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/m2m.py +170 -0
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/testing.py +69 -0
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/watch.py +106 -0
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/fixtures/__init__.py +28 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/registry.py +86 -34
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/utils.py +122 -87
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/watched.py +7 -2
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/pytest/plugin.py +128 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/pytest/utils.py +1 -1
- fastapi_toolsets-4.1.3/src/fastapi_toolsets/db.py +0 -591
- fastapi_toolsets-4.1.3/src/fastapi_toolsets/fixtures/__init__.py +0 -21
- fastapi_toolsets-4.1.3/src/fastapi_toolsets/pytest/plugin.py +0 -210
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/LICENSE +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/README.md +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/search.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/dependencies.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/columns.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/schemas.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/abc.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/oauth.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/header.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/multi.py +0 -0
- {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/types.py +0 -0
|
@@ -7,18 +7,21 @@ Example usage:
|
|
|
7
7
|
from fastapi import FastAPI, Depends
|
|
8
8
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
|
9
9
|
from fastapi_toolsets.crud import CrudFactory
|
|
10
|
-
from fastapi_toolsets.db import
|
|
10
|
+
from fastapi_toolsets.db import Database
|
|
11
11
|
from fastapi_toolsets.schemas import Response
|
|
12
12
|
|
|
13
|
+
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
|
14
|
+
|
|
13
15
|
app = FastAPI()
|
|
16
|
+
db.install(app)
|
|
14
17
|
init_exceptions_handlers(app)
|
|
15
18
|
|
|
16
19
|
UserCrud = CrudFactory(User)
|
|
17
20
|
|
|
18
21
|
@app.get("/users/{user_id}", response_model=Response[dict])
|
|
19
|
-
async def get_user(user_id: int, session = Depends(
|
|
22
|
+
async def get_user(user_id: int, session = Depends(db)):
|
|
20
23
|
user = await UserCrud.get(session, [User.id == user_id])
|
|
21
24
|
return Response(data={"user": user.username}, message="Success")
|
|
22
25
|
"""
|
|
23
26
|
|
|
24
|
-
__version__ = "
|
|
27
|
+
__version__ = "5.0.0b2"
|
{fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/fixtures.py
RENAMED
|
@@ -6,7 +6,8 @@ import typer
|
|
|
6
6
|
from rich.console import Console
|
|
7
7
|
from rich.table import Table
|
|
8
8
|
|
|
9
|
-
from ...fixtures import Context, LoadStrategy
|
|
9
|
+
from ...fixtures import Context, LoadStrategy
|
|
10
|
+
from ...logger import get_logger
|
|
10
11
|
from ..config import get_db_context, get_fixtures_registry
|
|
11
12
|
from ..utils import async_command
|
|
12
13
|
|
|
@@ -16,13 +17,14 @@ fixture_cli = typer.Typer(
|
|
|
16
17
|
no_args_is_help=True,
|
|
17
18
|
)
|
|
18
19
|
console = Console()
|
|
20
|
+
logger = get_logger()
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
@fixture_cli.command("list")
|
|
22
24
|
def list_fixtures(
|
|
23
25
|
ctx: typer.Context,
|
|
24
26
|
context: Annotated[
|
|
25
|
-
|
|
27
|
+
str | None,
|
|
26
28
|
typer.Option(
|
|
27
29
|
"--context",
|
|
28
30
|
"-c",
|
|
@@ -32,10 +34,10 @@ def list_fixtures(
|
|
|
32
34
|
) -> None:
|
|
33
35
|
"""List all registered fixtures."""
|
|
34
36
|
registry = get_fixtures_registry()
|
|
35
|
-
fixtures = registry.get_by_context(context
|
|
37
|
+
fixtures = registry.get_by_context(context) if context else registry.get_all()
|
|
36
38
|
|
|
37
39
|
if not fixtures:
|
|
38
|
-
|
|
40
|
+
logger.info("No fixtures found.")
|
|
39
41
|
return
|
|
40
42
|
|
|
41
43
|
table = Table("Name", "Contexts", "Dependencies")
|
|
@@ -46,7 +48,7 @@ def list_fixtures(
|
|
|
46
48
|
table.add_row(fixture.name, contexts, deps)
|
|
47
49
|
|
|
48
50
|
console.print(table)
|
|
49
|
-
|
|
51
|
+
logger.info("Total: %d fixture(s)", len(fixtures))
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
@fixture_cli.command("load")
|
|
@@ -54,7 +56,7 @@ def list_fixtures(
|
|
|
54
56
|
async def load(
|
|
55
57
|
ctx: typer.Context,
|
|
56
58
|
contexts: Annotated[
|
|
57
|
-
list[
|
|
59
|
+
list[str] | None,
|
|
58
60
|
typer.Argument(help="Contexts to load."),
|
|
59
61
|
] = None,
|
|
60
62
|
strategy: Annotated[
|
|
@@ -69,26 +71,27 @@ async def load(
|
|
|
69
71
|
] = False,
|
|
70
72
|
) -> None:
|
|
71
73
|
"""Load fixtures into the database."""
|
|
74
|
+
from ...fixtures import load_fixtures_by_context
|
|
75
|
+
|
|
72
76
|
registry = get_fixtures_registry()
|
|
73
77
|
db_context = get_db_context()
|
|
74
78
|
|
|
75
|
-
context_list =
|
|
79
|
+
context_list = contexts or [Context.BASE.value]
|
|
76
80
|
|
|
77
81
|
ordered = registry.resolve_context_dependencies(*context_list)
|
|
78
82
|
|
|
79
83
|
if not ordered:
|
|
80
|
-
|
|
84
|
+
logger.info("No fixtures to load for the specified context(s).")
|
|
81
85
|
return
|
|
82
86
|
|
|
83
|
-
print(f"\nFixtures to load ({strategy.value} strategy):")
|
|
84
|
-
for name in ordered:
|
|
85
|
-
fixture = registry.get(name)
|
|
86
|
-
instances = list(fixture.func())
|
|
87
|
-
model_name = type(instances[0]).__name__ if instances else "?"
|
|
88
|
-
print(f" - {name}: {len(instances)} {model_name}(s)")
|
|
89
|
-
|
|
90
87
|
if dry_run:
|
|
91
|
-
|
|
88
|
+
logger.info("Fixtures to load (%s strategy):", strategy.value)
|
|
89
|
+
for name in ordered:
|
|
90
|
+
variants = registry.get_load_variants(name, *context_list)
|
|
91
|
+
instances = [inst for v in variants for inst in v.func()]
|
|
92
|
+
model_name = type(instances[0]).__name__ if instances else "?"
|
|
93
|
+
logger.info(" - %s: %d %s(s)", name, len(instances), model_name)
|
|
94
|
+
logger.info("[Dry run - no changes made]")
|
|
92
95
|
return
|
|
93
96
|
|
|
94
97
|
async with db_context() as session:
|
|
@@ -97,4 +100,4 @@ async def load(
|
|
|
97
100
|
)
|
|
98
101
|
|
|
99
102
|
total = sum(len(items) for items in result.values())
|
|
100
|
-
|
|
103
|
+
logger.info("Loaded %d record(s) successfully.", total)
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import importlib
|
|
6
6
|
import sys
|
|
7
|
-
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
|
|
@@ -13,6 +13,8 @@ from .pyproject import find_pyproject, load_pyproject
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
14
|
from ..fixtures import FixtureRegistry
|
|
15
15
|
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
16
18
|
|
|
17
19
|
def _ensure_project_in_path():
|
|
18
20
|
"""Add project root to sys.path if not installed in editable mode."""
|
|
@@ -88,19 +90,39 @@ def get_config_value(key: str, required: bool = False) -> Any | None:
|
|
|
88
90
|
return value
|
|
89
91
|
|
|
90
92
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
@overload
|
|
94
|
+
def _import_typed(
|
|
95
|
+
key: str, expected_type: type[T], *, required: Literal[True]
|
|
96
|
+
) -> T: ... # pragma: no cover
|
|
97
|
+
@overload
|
|
98
|
+
def _import_typed(
|
|
99
|
+
key: str, expected_type: type[T], *, required: bool
|
|
100
|
+
) -> T | None: ... # pragma: no cover
|
|
101
|
+
def _import_typed(key: str, expected_type: type[T], *, required: bool) -> T | None:
|
|
102
|
+
"""Import a config value by key and validate its type.
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
Raises:
|
|
105
|
+
typer.BadParameter: If required and missing, or if the imported
|
|
106
|
+
value isn't an instance of *expected_type*.
|
|
107
|
+
"""
|
|
108
|
+
import_path = get_config_value(key, required=required)
|
|
109
|
+
if not import_path:
|
|
110
|
+
return None
|
|
97
111
|
|
|
98
|
-
|
|
112
|
+
obj = import_from_string(import_path)
|
|
113
|
+
if not isinstance(obj, expected_type):
|
|
99
114
|
raise typer.BadParameter(
|
|
100
|
-
f"'
|
|
115
|
+
f"'{key}' must be a {expected_type.__name__} instance, got {type(obj).__name__}"
|
|
101
116
|
)
|
|
102
117
|
|
|
103
|
-
return
|
|
118
|
+
return obj
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_fixtures_registry() -> FixtureRegistry:
|
|
122
|
+
"""Import and return the fixtures registry from config."""
|
|
123
|
+
from ..fixtures import FixtureRegistry
|
|
124
|
+
|
|
125
|
+
return _import_typed("fixtures", FixtureRegistry, required=True)
|
|
104
126
|
|
|
105
127
|
|
|
106
128
|
def get_db_context() -> Any:
|
|
@@ -111,15 +133,4 @@ def get_db_context() -> Any:
|
|
|
111
133
|
|
|
112
134
|
def get_custom_cli() -> typer.Typer | None:
|
|
113
135
|
"""Import and return the custom CLI Typer instance from config."""
|
|
114
|
-
|
|
115
|
-
if not import_path:
|
|
116
|
-
return None
|
|
117
|
-
|
|
118
|
-
custom = import_from_string(import_path)
|
|
119
|
-
|
|
120
|
-
if not isinstance(custom, typer.Typer):
|
|
121
|
-
raise typer.BadParameter(
|
|
122
|
-
f"'custom_cli' must be a Typer instance, got {type(custom).__name__}"
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
return custom
|
|
136
|
+
return _import_typed("custom_cli", typer.Typer, required=False)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"""CLI utility functions."""
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
import functools
|
|
5
4
|
from collections.abc import Callable, Coroutine
|
|
6
5
|
from typing import Any, ParamSpec, TypeVar
|
|
@@ -24,6 +23,8 @@ def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
|
|
|
24
23
|
|
|
25
24
|
@functools.wraps(func)
|
|
26
25
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
26
|
+
import asyncio
|
|
27
|
+
|
|
27
28
|
return asyncio.run(func(*args, **kwargs))
|
|
28
29
|
|
|
29
30
|
return wrapper
|
|
@@ -22,7 +22,7 @@ from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
|
|
22
22
|
from sqlalchemy.sql.base import ExecutableOption
|
|
23
23
|
from sqlalchemy.sql.roles import WhereHavingRole
|
|
24
24
|
|
|
25
|
-
from ..db import
|
|
25
|
+
from ..db import transaction
|
|
26
26
|
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
|
27
27
|
from ..schemas import (
|
|
28
28
|
CursorPaginatedResponse,
|
|
@@ -716,7 +716,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
716
716
|
Returns:
|
|
717
717
|
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
|
718
718
|
"""
|
|
719
|
-
async with
|
|
719
|
+
async with transaction(session):
|
|
720
720
|
m2m_exclude = cls._m2m_schema_fields()
|
|
721
721
|
data = (
|
|
722
722
|
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
|
|
@@ -1067,7 +1067,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1067
1067
|
Raises:
|
|
1068
1068
|
NotFoundError: If no record found
|
|
1069
1069
|
"""
|
|
1070
|
-
async with
|
|
1070
|
+
async with transaction(session):
|
|
1071
1071
|
m2m_exclude = cls._m2m_schema_fields()
|
|
1072
1072
|
|
|
1073
1073
|
# Eagerly load M2M relationships that will be updated so that
|
|
@@ -1127,7 +1127,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1127
1127
|
Returns:
|
|
1128
1128
|
Model instance
|
|
1129
1129
|
"""
|
|
1130
|
-
async with
|
|
1130
|
+
async with transaction(session):
|
|
1131
1131
|
values = obj.model_dump(exclude_unset=True)
|
|
1132
1132
|
q = insert(cls.model).values(**values)
|
|
1133
1133
|
if set_:
|
|
@@ -1189,7 +1189,7 @@ class AsyncCrud(Generic[ModelType]):
|
|
|
1189
1189
|
Returns:
|
|
1190
1190
|
``None``, or ``Response[None]`` when ``return_response=True``.
|
|
1191
1191
|
"""
|
|
1192
|
-
async with
|
|
1192
|
+
async with transaction(session):
|
|
1193
1193
|
result = await session.execute(select(cls.model).where(and_(*filters)))
|
|
1194
1194
|
objects = result.scalars().all()
|
|
1195
1195
|
for obj in objects:
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Database package: the ``Database`` facade plus PostgreSQL power-tools."""
|
|
2
|
+
|
|
3
|
+
from .core import Database, transaction
|
|
4
|
+
from .locks import LockMode, advisory_lock, lock_tables
|
|
5
|
+
from .m2m import m2m_add, m2m_remove, m2m_set
|
|
6
|
+
from .watch import wait_for_row_change
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Database",
|
|
10
|
+
"LockMode",
|
|
11
|
+
"advisory_lock",
|
|
12
|
+
"lock_tables",
|
|
13
|
+
"m2m_add",
|
|
14
|
+
"m2m_remove",
|
|
15
|
+
"m2m_set",
|
|
16
|
+
"transaction",
|
|
17
|
+
"wait_for_row_change",
|
|
18
|
+
]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""The ``Database`` facade: session lifecycle, dependency, middleware, transactions."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import PostgresDsn
|
|
8
|
+
from sqlalchemy import exc as sa_exc
|
|
9
|
+
from sqlalchemy.ext.asyncio import (
|
|
10
|
+
AsyncEngine,
|
|
11
|
+
AsyncSession,
|
|
12
|
+
async_sessionmaker,
|
|
13
|
+
create_async_engine,
|
|
14
|
+
)
|
|
15
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
16
|
+
from starlette.requests import Request
|
|
17
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
18
|
+
|
|
19
|
+
from ..exceptions import PoolExhaustedError
|
|
20
|
+
from .locks import LockMode, lock_tables
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@asynccontextmanager
|
|
24
|
+
async def transaction(
|
|
25
|
+
session: AsyncSession,
|
|
26
|
+
) -> AsyncGenerator[AsyncSession, None]:
|
|
27
|
+
"""Run a block inside a savepoint-aware transaction.
|
|
28
|
+
|
|
29
|
+
If *session* is already in a transaction, a nested transaction (savepoint)
|
|
30
|
+
is opened so the block can roll back independently. Otherwise a top-level
|
|
31
|
+
transaction is started. Commits on clean exit, rolls back on exception.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
session: AsyncSession instance.
|
|
35
|
+
|
|
36
|
+
Yields:
|
|
37
|
+
The session within the transaction context.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
```python
|
|
41
|
+
from fastapi_toolsets.db import transaction
|
|
42
|
+
|
|
43
|
+
async with transaction(session):
|
|
44
|
+
session.add(model)
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
if session.in_transaction():
|
|
48
|
+
async with session.begin_nested():
|
|
49
|
+
yield session
|
|
50
|
+
else:
|
|
51
|
+
async with session.begin():
|
|
52
|
+
yield session
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _CommitOnResponseMiddleware:
|
|
56
|
+
"""Commit the request's DB session before the response is sent."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, app: ASGIApp, *, state_attr: str) -> None:
|
|
59
|
+
self.app = app
|
|
60
|
+
self.state_attr = state_attr
|
|
61
|
+
|
|
62
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
63
|
+
if scope["type"] != "http":
|
|
64
|
+
await self.app(scope, receive, send)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
async def send_wrapper(message: Message) -> None:
|
|
68
|
+
if message["type"] == "http.response.start":
|
|
69
|
+
# ``scope["state"]`` is the same dict ``request.state`` writes
|
|
70
|
+
# to, so this is the session stashed by the dependency.
|
|
71
|
+
state = scope.get("state")
|
|
72
|
+
session = state.get(self.state_attr) if state else None
|
|
73
|
+
if session is not None and session.in_transaction():
|
|
74
|
+
await session.commit()
|
|
75
|
+
await send(message)
|
|
76
|
+
|
|
77
|
+
await self.app(scope, receive, send_wrapper)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Database:
|
|
81
|
+
"""One object that owns the engine, sessions, dependency, and middleware.
|
|
82
|
+
|
|
83
|
+
Provide exactly one of *url* (the facade builds and disposes the engine) or
|
|
84
|
+
*engine* (an engine you own, e.g. for Alembic or ``event.listen``, left
|
|
85
|
+
untouched).
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
url: Database connection URL. Accepts a plain string or a Pydantic
|
|
89
|
+
:class:`~pydantic.PostgresDsn`.
|
|
90
|
+
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
|
|
91
|
+
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
|
|
92
|
+
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
|
|
93
|
+
autoflush: Autoflush the session before queries. Defaults to ``True``.
|
|
94
|
+
connect_args: DBAPI-level connection arguments forwarded to
|
|
95
|
+
:func:`create_async_engine` (URL mode only).
|
|
96
|
+
**engine_options: Extra keyword arguments forwarded to
|
|
97
|
+
:func:`create_async_engine` (URL mode only).
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
TypeError: If neither or both of *url* and *engine* are given, or if
|
|
101
|
+
*connect_args*/*engine_options* are passed together with *engine*.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
```python
|
|
105
|
+
from fastapi import Depends, FastAPI
|
|
106
|
+
from fastapi_toolsets.db import Database
|
|
107
|
+
|
|
108
|
+
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
|
109
|
+
|
|
110
|
+
app = FastAPI()
|
|
111
|
+
db.install(app)
|
|
112
|
+
|
|
113
|
+
@app.get("/users/{user_id}")
|
|
114
|
+
async def get_user(user_id: int, session=Depends(db)):
|
|
115
|
+
return await UserCrud.get(session, [User.id == user_id])
|
|
116
|
+
```
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
url: str | PostgresDsn | None = None,
|
|
122
|
+
*,
|
|
123
|
+
engine: AsyncEngine | None = None,
|
|
124
|
+
session_class: type[AsyncSession] = AsyncSession,
|
|
125
|
+
expire_on_commit: bool = False,
|
|
126
|
+
autoflush: bool = True,
|
|
127
|
+
connect_args: dict[str, Any] | None = None,
|
|
128
|
+
**engine_options: Any,
|
|
129
|
+
) -> None:
|
|
130
|
+
if (url is None) == (engine is None):
|
|
131
|
+
raise TypeError(
|
|
132
|
+
"Database requires exactly one of 'url' or 'engine' "
|
|
133
|
+
"(got both or neither)."
|
|
134
|
+
)
|
|
135
|
+
if engine is not None and (engine_options or connect_args is not None):
|
|
136
|
+
raise TypeError(
|
|
137
|
+
"connect_args/engine_options are only valid in URL mode; "
|
|
138
|
+
"configure the engine you pass via 'engine=' yourself."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if engine is not None:
|
|
142
|
+
self._owns_engine = False
|
|
143
|
+
self.engine: AsyncEngine = engine
|
|
144
|
+
else:
|
|
145
|
+
assert url is not None # guaranteed by the XOR check above
|
|
146
|
+
self._owns_engine = True
|
|
147
|
+
if connect_args is not None:
|
|
148
|
+
engine_options["connect_args"] = connect_args
|
|
149
|
+
# ``PostgresDsn`` (and other URL objects) are not str subclasses, so
|
|
150
|
+
# coerce to the string form SQLAlchemy expects.
|
|
151
|
+
self.engine = create_async_engine(str(url), **engine_options)
|
|
152
|
+
self._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
|
153
|
+
self.engine,
|
|
154
|
+
class_=session_class,
|
|
155
|
+
expire_on_commit=expire_on_commit,
|
|
156
|
+
autoflush=autoflush,
|
|
157
|
+
)
|
|
158
|
+
# Private, per-instance state attribute; cannot collide with another
|
|
159
|
+
# Database or be mismatched against the middleware.
|
|
160
|
+
self._state_attr = f"_ft_db_session_{id(self):x}"
|
|
161
|
+
self._middleware_installed = False
|
|
162
|
+
self._disposed = False
|
|
163
|
+
|
|
164
|
+
async def _dispose(self) -> None:
|
|
165
|
+
"""Dispose the engine once, only if we own it (idempotent)."""
|
|
166
|
+
if self._owns_engine and not self._disposed:
|
|
167
|
+
self._disposed = True
|
|
168
|
+
await self.engine.dispose()
|
|
169
|
+
|
|
170
|
+
@asynccontextmanager
|
|
171
|
+
async def lifespan(self, app: Any) -> AsyncGenerator[None, None]:
|
|
172
|
+
"""Dispose the engine on shutdown; use as ``FastAPI(lifespan=db.lifespan)``.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
app: The ASGI application (unused; required by the lifespan protocol).
|
|
176
|
+
|
|
177
|
+
Yields:
|
|
178
|
+
Control to the application for its lifetime.
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
```python
|
|
182
|
+
app = FastAPI(lifespan=db.lifespan)
|
|
183
|
+
```
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
yield
|
|
187
|
+
finally:
|
|
188
|
+
await self._dispose()
|
|
189
|
+
|
|
190
|
+
def install(self, app: Any) -> None:
|
|
191
|
+
"""Wire the commit middleware and engine disposal onto *app*.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
app: The FastAPI/Starlette application to wire.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
```python
|
|
198
|
+
@asynccontextmanager
|
|
199
|
+
async def lifespan(app):
|
|
200
|
+
... # your startup
|
|
201
|
+
yield
|
|
202
|
+
... # your shutdown
|
|
203
|
+
|
|
204
|
+
app = FastAPI(lifespan=lifespan)
|
|
205
|
+
db.install(app)
|
|
206
|
+
```
|
|
207
|
+
"""
|
|
208
|
+
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
|
|
209
|
+
self._middleware_installed = True
|
|
210
|
+
|
|
211
|
+
inner_lifespan = app.router.lifespan_context
|
|
212
|
+
|
|
213
|
+
@asynccontextmanager
|
|
214
|
+
async def _composed(app_: Any) -> AsyncGenerator[None, None]:
|
|
215
|
+
async with self.lifespan(app_):
|
|
216
|
+
async with inner_lifespan(app_):
|
|
217
|
+
yield
|
|
218
|
+
|
|
219
|
+
app.router.lifespan_context = _composed
|
|
220
|
+
|
|
221
|
+
@asynccontextmanager
|
|
222
|
+
async def _open(self) -> AsyncGenerator[AsyncSession, None]:
|
|
223
|
+
"""Open a session and eagerly acquire a connection (fail-fast on pool)."""
|
|
224
|
+
async with self._sessionmaker() as session:
|
|
225
|
+
try:
|
|
226
|
+
await session.connection()
|
|
227
|
+
except sa_exc.TimeoutError as e:
|
|
228
|
+
raise PoolExhaustedError() from e
|
|
229
|
+
yield session
|
|
230
|
+
|
|
231
|
+
async def __call__(self, request: Request) -> AsyncGenerator[AsyncSession, None]:
|
|
232
|
+
"""FastAPI dependency: yield a session and commit once at the right time.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
request: The incoming request (injected by FastAPI).
|
|
236
|
+
|
|
237
|
+
Yields:
|
|
238
|
+
An AsyncSession for the duration of the request.
|
|
239
|
+
|
|
240
|
+
Example:
|
|
241
|
+
```python
|
|
242
|
+
@app.get("/users/{user_id}")
|
|
243
|
+
async def get_user(user_id: int, session=Depends(db)):
|
|
244
|
+
return await UserCrud.get(session, [User.id == user_id])
|
|
245
|
+
```
|
|
246
|
+
"""
|
|
247
|
+
async with self._open() as session:
|
|
248
|
+
setattr(request.state, self._state_attr, session)
|
|
249
|
+
yield session
|
|
250
|
+
if not self._middleware_installed and session.in_transaction():
|
|
251
|
+
await session.commit()
|
|
252
|
+
|
|
253
|
+
@asynccontextmanager
|
|
254
|
+
async def session(self) -> AsyncGenerator[AsyncSession, None]:
|
|
255
|
+
"""Open a session outside request handlers (background tasks, CLI, tests).
|
|
256
|
+
|
|
257
|
+
Commits on clean exit, rolls back on exception.
|
|
258
|
+
|
|
259
|
+
Yields:
|
|
260
|
+
An AsyncSession ready for database operations.
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
```python
|
|
264
|
+
async with db.session() as session:
|
|
265
|
+
user = await UserCrud.get(session, [User.id == 1])
|
|
266
|
+
```
|
|
267
|
+
"""
|
|
268
|
+
async with self._open() as session:
|
|
269
|
+
yield session
|
|
270
|
+
if session.in_transaction():
|
|
271
|
+
await session.commit()
|
|
272
|
+
|
|
273
|
+
@asynccontextmanager
|
|
274
|
+
async def begin(self) -> AsyncGenerator[AsyncSession, None]:
|
|
275
|
+
"""Open a session already inside a transaction (sugar for the common case).
|
|
276
|
+
|
|
277
|
+
Equivalent to ``session()`` + :func:`transaction`. Commits on clean exit,
|
|
278
|
+
rolls back on exception.
|
|
279
|
+
|
|
280
|
+
Yields:
|
|
281
|
+
An AsyncSession open within a transaction.
|
|
282
|
+
|
|
283
|
+
Example:
|
|
284
|
+
```python
|
|
285
|
+
async with db.begin() as session:
|
|
286
|
+
session.add(User(name="ada"))
|
|
287
|
+
```
|
|
288
|
+
"""
|
|
289
|
+
async with self.session() as session, transaction(session):
|
|
290
|
+
yield session
|
|
291
|
+
|
|
292
|
+
def lock_tables(
|
|
293
|
+
self,
|
|
294
|
+
tables: list[type[DeclarativeBase]],
|
|
295
|
+
*,
|
|
296
|
+
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
|
297
|
+
timeout: str = "5s",
|
|
298
|
+
) -> AbstractAsyncContextManager[AsyncSession]:
|
|
299
|
+
"""Lock PostgreSQL tables for the duration of a dedicated transaction.
|
|
300
|
+
|
|
301
|
+
Opens its own session from the facade's sessionmaker, changes are
|
|
302
|
+
committed when the context exits.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
tables: List of SQLAlchemy model classes to lock.
|
|
306
|
+
mode: Lock mode (default: ``SHARE UPDATE EXCLUSIVE``).
|
|
307
|
+
timeout: Lock timeout (default: ``"5s"``).
|
|
308
|
+
|
|
309
|
+
Yields:
|
|
310
|
+
The dedicated session, open within the locked transaction.
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
LockTimeoutError: If the lock cannot be acquired within *timeout*.
|
|
314
|
+
PoolExhaustedError: If the connection pool is exhausted.
|
|
315
|
+
|
|
316
|
+
Example:
|
|
317
|
+
```python
|
|
318
|
+
async with db.lock_tables([User, Account]) as session:
|
|
319
|
+
user = await UserCrud.get(session, [User.id == 1])
|
|
320
|
+
user.balance += 100
|
|
321
|
+
```
|
|
322
|
+
"""
|
|
323
|
+
return lock_tables(self._sessionmaker, tables, mode=mode, timeout=timeout)
|