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.
Files changed (54) hide show
  1. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/PKG-INFO +1 -1
  2. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/pyproject.toml +1 -1
  3. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/__init__.py +6 -3
  4. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/fixtures.py +20 -17
  5. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/config.py +32 -21
  6. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/utils.py +2 -1
  7. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/factory.py +5 -5
  8. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/__init__.py +18 -0
  9. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/core.py +323 -0
  10. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/locks.py +185 -0
  11. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/m2m.py +170 -0
  12. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/testing.py +69 -0
  13. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/db/watch.py +106 -0
  14. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/fixtures/__init__.py +28 -0
  15. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/registry.py +86 -34
  16. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/utils.py +122 -87
  17. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/watched.py +7 -2
  18. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/pytest/plugin.py +128 -0
  19. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/pytest/utils.py +1 -1
  20. fastapi_toolsets-4.1.3/src/fastapi_toolsets/db.py +0 -591
  21. fastapi_toolsets-4.1.3/src/fastapi_toolsets/fixtures/__init__.py +0 -21
  22. fastapi_toolsets-4.1.3/src/fastapi_toolsets/pytest/plugin.py +0 -210
  23. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/LICENSE +0 -0
  24. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/README.md +0 -0
  25. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/_imports.py +0 -0
  26. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/__init__.py +0 -0
  27. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/app.py +0 -0
  28. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  29. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  30. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/__init__.py +0 -0
  31. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/search.py +0 -0
  32. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/dependencies.py +0 -0
  33. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  34. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  35. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  36. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  37. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/logger.py +0 -0
  38. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  39. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/handler.py +0 -0
  40. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/registry.py +0 -0
  41. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/__init__.py +0 -0
  42. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/columns.py +0 -0
  43. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/py.typed +0 -0
  44. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  45. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/schemas.py +0 -0
  46. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/__init__.py +0 -0
  47. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/abc.py +0 -0
  48. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/oauth.py +0 -0
  49. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
  50. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
  51. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
  52. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/header.py +0 -0
  53. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/multi.py +0 -0
  54. {fastapi_toolsets-4.1.3 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 4.1.3
3
+ Version: 5.0.0b2
4
4
  Summary: Production-ready utilities for FastAPI applications
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "4.1.3"
3
+ version = "5.0.0b2"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 create_db_dependency
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(get_db)):
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__ = "4.1.3"
27
+ __version__ = "5.0.0b2"
@@ -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, load_fixtures_by_context
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
- Context | None,
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.value) if context else registry.get_all()
37
+ fixtures = registry.get_by_context(context) if context else registry.get_all()
36
38
 
37
39
  if not fixtures:
38
- print("No fixtures found.")
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
- print(f"\nTotal: {len(fixtures)} fixture(s)")
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[Context] | None,
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 = list(contexts) if contexts else [Context.BASE]
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
- print("No fixtures to load for the specified context(s).")
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
- print("\n[Dry run - no changes made]")
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
- print(f"\nLoaded {total} record(s) successfully.")
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
- def get_fixtures_registry() -> FixtureRegistry:
92
- """Import and return the fixtures registry from config."""
93
- from ..fixtures import FixtureRegistry
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
- import_path = get_config_value("fixtures", required=True)
96
- registry = import_from_string(import_path)
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
- if not isinstance(registry, FixtureRegistry):
112
+ obj = import_from_string(import_path)
113
+ if not isinstance(obj, expected_type):
99
114
  raise typer.BadParameter(
100
- f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
115
+ f"'{key}' must be a {expected_type.__name__} instance, got {type(obj).__name__}"
101
116
  )
102
117
 
103
- return registry
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
- import_path = get_config_value("custom_cli")
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 get_transaction
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 get_transaction(session):
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 get_transaction(session):
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 get_transaction(session):
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 get_transaction(session):
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)