drfast-api 0.1.0__py3-none-any.whl

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.
drfast/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """DRF-inspired primitives for async FastAPI applications."""
2
+
3
+ from drfast.filters import FilterSet
4
+ from drfast.middleware import BaseMiddleware
5
+ from drfast.models import Model, SoftDeleteModel, TimestampedModel
6
+ from drfast.pagination import LimitOffsetPagination
7
+ from drfast.permissions import AllowAny, BasePermission, IsAdminUser, IsAuthenticated
8
+ from drfast.routers import DefaultRouter
9
+ from drfast.serializers import (
10
+ BooleanField,
11
+ CharField,
12
+ EmailField,
13
+ IntegerField,
14
+ ModelSerializer,
15
+ Serializer,
16
+ UUIDField,
17
+ )
18
+ from drfast.settings import DRFastSettings
19
+ from drfast.views import APIView, ModelViewSet
20
+
21
+ __all__ = [
22
+ "APIView",
23
+ "AllowAny",
24
+ "BaseMiddleware",
25
+ "BasePermission",
26
+ "BooleanField",
27
+ "CharField",
28
+ "DRFastSettings",
29
+ "DefaultRouter",
30
+ "EmailField",
31
+ "FilterSet",
32
+ "IntegerField",
33
+ "IsAdminUser",
34
+ "IsAuthenticated",
35
+ "LimitOffsetPagination",
36
+ "Model",
37
+ "ModelSerializer",
38
+ "ModelViewSet",
39
+ "Serializer",
40
+ "SoftDeleteModel",
41
+ "TimestampedModel",
42
+ "UUIDField",
43
+ ]
44
+
drfast/auth.py ADDED
@@ -0,0 +1,126 @@
1
+ """Current FastAPI Users integration backed by Tortoise ORM.
2
+
3
+ FastAPI Users no longer ships a Tortoise adapter. This module maintains the
4
+ small persistence bridge while leaving its authentication and security flows
5
+ to FastAPI Users.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncGenerator, Callable
11
+ from typing import Any, Generic, Protocol, Self, TypeVar
12
+ from uuid import UUID
13
+
14
+ from fastapi import Depends, Request
15
+ from fastapi_users import BaseUserManager, FastAPIUsers
16
+ from fastapi_users.authentication import AuthenticationBackend, BearerTransport, JWTStrategy
17
+ from fastapi_users.db import BaseUserDatabase
18
+ from fastapi_users.manager import UUIDIDMixin
19
+ from fastapi_users.models import UserProtocol
20
+ from tortoise import fields
21
+
22
+ from drfast.models import Model, TimestampedModel
23
+
24
+
25
+ class TortoiseUserProtocol(UserProtocol[UUID], Protocol):
26
+ """Structural contract required by the Tortoise FastAPI Users adapter."""
27
+
28
+ @classmethod
29
+ async def get_or_none(cls, **kwargs: Any) -> Self | None: ...
30
+
31
+ @classmethod
32
+ async def create(cls, **kwargs: Any) -> Self: ...
33
+
34
+ async def save(self, *, update_fields: list[str]) -> None: ...
35
+
36
+ async def delete(self) -> None: ...
37
+
38
+
39
+ UserType = TypeVar("UserType", bound=TortoiseUserProtocol)
40
+ AuthUserType = TypeVar("AuthUserType", bound=UserProtocol[UUID])
41
+
42
+
43
+ class FastAPIUserModel(Model, TimestampedModel):
44
+ """Ready-to-use Tortoise user table base with FastAPI Users fields."""
45
+
46
+ email = fields.CharField(max_length=320, unique=True, index=True)
47
+ hashed_password = fields.CharField(max_length=1024)
48
+ is_active = fields.BooleanField(default=True)
49
+ is_superuser = fields.BooleanField(default=False)
50
+ is_verified = fields.BooleanField(default=False)
51
+
52
+ class Meta:
53
+ abstract = True
54
+
55
+
56
+ class TortoiseUserDatabase(BaseUserDatabase[UserType, UUID], Generic[UserType]):
57
+ """FastAPI Users persistence adapter for a Tortoise UUID user model."""
58
+
59
+ def __init__(self, user_model: type[UserType]) -> None:
60
+ self.user_model = user_model
61
+
62
+ async def get(self, id: UUID) -> UserType | None:
63
+ return await self.user_model.get_or_none(id=id)
64
+
65
+ async def get_by_email(self, email: str) -> UserType | None:
66
+ return await self.user_model.get_or_none(email=email)
67
+
68
+ async def create(self, create_dict: dict[str, Any]) -> UserType:
69
+ return await self.user_model.create(**create_dict)
70
+
71
+ async def update(self, user: UserType, update_dict: dict[str, Any]) -> UserType:
72
+ for key, value in update_dict.items():
73
+ setattr(user, key, value)
74
+ await user.save(update_fields=list(update_dict))
75
+ return user
76
+
77
+ async def delete(self, user: UserType) -> None:
78
+ await user.delete()
79
+
80
+
81
+ class DRFastUserManager(UUIDIDMixin, BaseUserManager[UserType, UUID], Generic[UserType]):
82
+ """Base manager applications subclass to add password and event policies."""
83
+
84
+ reset_password_token_secret: str
85
+ verification_token_secret: str
86
+
87
+
88
+ def jwt_authentication(
89
+ secret: str,
90
+ lifetime_seconds: int = 3600,
91
+ ) -> AuthenticationBackend[AuthUserType, UUID]:
92
+ """Build FastAPI Users' bearer/JWT backend for generated projects."""
93
+ bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
94
+
95
+ def get_strategy() -> JWTStrategy[AuthUserType, UUID]:
96
+ return JWTStrategy(secret=secret, lifetime_seconds=lifetime_seconds)
97
+
98
+ return AuthenticationBackend[AuthUserType, UUID](
99
+ name="jwt", transport=bearer_transport, get_strategy=get_strategy
100
+ )
101
+
102
+
103
+ def build_fastapi_users(
104
+ get_user_manager: Callable[..., AsyncGenerator[DRFastUserManager[UserType], None]],
105
+ backend: AuthenticationBackend[UserType, UUID],
106
+ ) -> FastAPIUsers[UserType, UUID]:
107
+ """Create a FastAPI Users facade from application dependencies."""
108
+ return FastAPIUsers(get_user_manager, [backend])
109
+
110
+
111
+ def current_user_middleware(
112
+ current_active_user: Callable[..., Any],
113
+ ) -> Callable[[Request, Any], Any]:
114
+ """Adapt a FastAPI Users dependency for DRFast permission classes.
115
+
116
+ Use this with a normal FastAPI dependency on protected routes when request
117
+ state is desired by custom `BasePermission` implementations.
118
+ """
119
+ async def dependency( # noqa: B008 - FastAPI resolves dependencies from the signature.
120
+ request: Request, user: Any = Depends(current_active_user) # noqa: B008
121
+ ) -> Any:
122
+ request.state.user = user
123
+ return user
124
+
125
+ return dependency
126
+
drfast/cli.py ADDED
@@ -0,0 +1,358 @@
1
+ """Django-inspired management CLI for DRFast projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import importlib
8
+ import runpy
9
+ import sys
10
+ from getpass import getpass
11
+ from pathlib import Path
12
+ from textwrap import dedent
13
+
14
+
15
+ def _write(path: Path, content: str) -> None:
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ path.write_text(dedent(content).lstrip(), encoding="utf-8")
18
+
19
+
20
+ def startproject(name: str, destination: str) -> None:
21
+ """Generate a runnable application with local SQLite configuration."""
22
+ if not name.isidentifier():
23
+ raise SystemExit("Project name must be a valid Python identifier.")
24
+ root = Path(destination).resolve() / name
25
+ if root.exists() and any(root.iterdir()):
26
+ raise SystemExit(f"Refusing to overwrite non-empty directory: {root}")
27
+ package = name
28
+ _write(
29
+ root / "pyproject.toml",
30
+ f'''
31
+ [project]
32
+ name = "{name}"
33
+ version = "0.1.0"
34
+ requires-python = ">=3.11"
35
+ dependencies = ["drfast-api[postgres]"]
36
+
37
+ [tool.tortoise]
38
+ tortoise_orm = "{package}.settings.TORTOISE_ORM"
39
+ ''',
40
+ )
41
+ _write(root / ".env.example", """DEBUG=true
42
+ DATABASE_URL=sqlite://db.sqlite3
43
+ SECRET_KEY=replace-before-production
44
+ """)
45
+ _write(root / package / "__init__.py", "")
46
+ _write(
47
+ root / package / "settings.py",
48
+ f'''
49
+ from drfast import DRFastSettings
50
+
51
+
52
+ class Settings(DRFastSettings):
53
+ app_name: str = "{name}"
54
+ debug: bool = True
55
+ installed_apps: list[str] = [
56
+ "{package}.users.models",
57
+ "{package}.apps.todo.models",
58
+ ]
59
+
60
+
61
+ settings = Settings()
62
+ TORTOISE_ORM = settings.tortoise_config()
63
+ ''',
64
+ )
65
+ _write(root / package / "users" / "__init__.py", "")
66
+ _write(
67
+ root / package / "users" / "models.py",
68
+ """
69
+ from drfast.auth import FastAPIUserModel
70
+
71
+
72
+ class User(FastAPIUserModel):
73
+ pass
74
+ """,
75
+ )
76
+ _write(
77
+ root / package / "users" / "auth.py",
78
+ f'''
79
+ from collections.abc import AsyncGenerator
80
+ from uuid import UUID
81
+
82
+ from fastapi import Depends
83
+ from fastapi_users import schemas
84
+ from drfast.auth import (
85
+ DRFastUserManager,
86
+ TortoiseUserDatabase,
87
+ build_fastapi_users,
88
+ jwt_authentication,
89
+ )
90
+
91
+ from {package}.settings import settings
92
+ from {package}.users.models import User
93
+
94
+
95
+ class UserRead(schemas.BaseUser[UUID]):
96
+ pass
97
+
98
+
99
+ class UserCreate(schemas.BaseUserCreate):
100
+ pass
101
+
102
+
103
+ class UserUpdate(schemas.BaseUserUpdate):
104
+ pass
105
+
106
+
107
+ async def get_user_db() -> AsyncGenerator[TortoiseUserDatabase[User], None]:
108
+ yield TortoiseUserDatabase(User)
109
+
110
+
111
+ class UserManager(DRFastUserManager[User]):
112
+ reset_password_token_secret = settings.secret_key.get_secret_value()
113
+ verification_token_secret = settings.secret_key.get_secret_value()
114
+
115
+
116
+ async def get_user_manager(
117
+ user_db: TortoiseUserDatabase[User] = Depends(get_user_db),
118
+ ) -> AsyncGenerator[UserManager, None]:
119
+ yield UserManager(user_db)
120
+
121
+
122
+ auth_backend = jwt_authentication(
123
+ settings.secret_key.get_secret_value(), settings.jwt_lifetime_seconds
124
+ )
125
+ fastapi_users = build_fastapi_users(get_user_manager, auth_backend)
126
+ ''',
127
+ )
128
+ _write(root / package / "apps" / "__init__.py", "")
129
+ _write(root / package / "apps" / "todo" / "__init__.py", "")
130
+ _write(
131
+ root / package / "apps" / "todo" / "models.py",
132
+ """
133
+ from tortoise import fields
134
+ from drfast import Model, TimestampedModel
135
+
136
+
137
+ class Todo(Model, TimestampedModel):
138
+ title = fields.CharField(max_length=200)
139
+ completed = fields.BooleanField(default=False)
140
+ """,
141
+ )
142
+ _write(
143
+ root / package / "apps" / "todo" / "serializers.py",
144
+ f'''
145
+ from drfast import BooleanField, CharField, ModelSerializer, UUIDField
146
+ from {package}.apps.todo.models import Todo
147
+
148
+
149
+ class TodoSerializer(ModelSerializer[Todo]):
150
+ model = Todo
151
+ id = UUIDField(read_only=True)
152
+ title = CharField()
153
+ completed = BooleanField(required=False, default=False)
154
+ ''',
155
+ )
156
+ _write(
157
+ root / package / "apps" / "todo" / "views.py",
158
+ f'''
159
+ from drfast import FilterSet, ModelViewSet
160
+ from {package}.apps.todo.models import Todo
161
+ from {package}.apps.todo.serializers import TodoSerializer
162
+
163
+
164
+ class TodoFilters(FilterSet):
165
+ fields = {{"completed": ("exact",)}}
166
+
167
+
168
+ class TodoViewSet(ModelViewSet):
169
+ model = Todo
170
+ serializer_class = TodoSerializer
171
+ filterset_class = TodoFilters
172
+ ordering_fields = ("created_at", "title")
173
+ ''',
174
+ )
175
+ _write(
176
+ root / package / "urls.py",
177
+ f'''
178
+ from drfast import DefaultRouter
179
+ from {package}.apps.todo.views import TodoViewSet
180
+
181
+ router = DefaultRouter()
182
+ router.register("/todos", TodoViewSet, basename="todos")
183
+ ''',
184
+ )
185
+ _write(
186
+ root / package / "main.py",
187
+ f'''
188
+ from drfast.orm import create_app
189
+ from {package}.settings import settings
190
+ from {package}.urls import router
191
+ from {package}.users.auth import (
192
+ UserCreate,
193
+ UserRead,
194
+ UserUpdate,
195
+ auth_backend,
196
+ fastapi_users,
197
+ )
198
+
199
+ app = create_app(settings)
200
+ router.include_in(app, prefix="/api")
201
+ app.include_router(
202
+ fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
203
+ )
204
+ app.include_router(
205
+ fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", tags=["auth"]
206
+ )
207
+ app.include_router(
208
+ fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users", tags=["users"]
209
+ )
210
+ ''',
211
+ )
212
+ _write(
213
+ root / "README.md",
214
+ f"# {name}\n\nRun `drfast init`, `drfast migrate`, then `drfast runserver`.\n",
215
+ )
216
+ print(f"Created {root}")
217
+
218
+
219
+ def startapp(name: str, project: str = ".") -> None:
220
+ """Generate a conventional models/serializers/views app package."""
221
+ if not name.isidentifier():
222
+ raise SystemExit("App name must be a valid Python identifier.")
223
+ project_root = Path(project).resolve()
224
+ root = project_root / "apps" / name
225
+ if root.exists():
226
+ raise SystemExit(f"App already exists: {root}")
227
+ for filename in ("__init__.py", "models.py", "serializers.py", "views.py", "tests.py"):
228
+ _write(root / filename, "")
229
+ package = project_root.name.replace("-", "_")
230
+ settings_path = project_root / package / "settings.py"
231
+ if settings_path.exists():
232
+ settings = settings_path.read_text(encoding="utf-8")
233
+ marker = "\t]\n\n\nsettings = Settings()"
234
+ entry = f'\t\t"{package}.apps.{name}.models",\n'
235
+ if marker not in settings:
236
+ raise SystemExit(f"Could not update installed_apps in {settings_path}")
237
+ settings_path.write_text(settings.replace(marker, f"{entry}{marker}", 1), encoding="utf-8")
238
+ print(f"Created {root}")
239
+
240
+
241
+ def _run_tortoise(arguments: list[str]) -> int:
242
+ """Run Tortoise's supported module CLI without relying on ``PATH``."""
243
+ original_argv = sys.argv
244
+ try:
245
+ sys.argv = ["tortoise", *arguments]
246
+ runpy.run_module("tortoise", run_name="__main__")
247
+ except SystemExit as exc:
248
+ return exc.code if isinstance(exc.code, int) else 1
249
+ finally:
250
+ sys.argv = original_argv
251
+ return 0
252
+
253
+
254
+ async def _create_superuser(project: str, email: str) -> None:
255
+ settings_module = importlib.import_module(f"{project}.settings")
256
+ user_module = importlib.import_module(f"{project}.users.models")
257
+ from fastapi_users.password import PasswordHelper
258
+ from tortoise import Tortoise
259
+
260
+ password = getpass("Password: ")
261
+ confirm = getpass("Password (again): ")
262
+ if password != confirm:
263
+ raise SystemExit("Passwords do not match.")
264
+ await Tortoise.init(config=settings_module.TORTOISE_ORM)
265
+ try:
266
+ await user_module.User.create(
267
+ email=email,
268
+ hashed_password=PasswordHelper().hash(password),
269
+ is_active=True,
270
+ is_superuser=True,
271
+ is_verified=True,
272
+ )
273
+ finally:
274
+ await Tortoise.close_connections()
275
+
276
+
277
+ def _project_name() -> str:
278
+ return Path.cwd().name.replace("-", "_")
279
+
280
+
281
+ def _validate_project_name(project: str) -> str:
282
+ if not project or not all(part.isidentifier() for part in project.split(".")):
283
+ raise SystemExit("Project must be a dotted Python module name.")
284
+ return project
285
+
286
+
287
+ def main(argv: list[str] | None = None) -> None:
288
+ parser = argparse.ArgumentParser(prog="drfast")
289
+ subparsers = parser.add_subparsers(dest="command", required=True)
290
+ project_parser = subparsers.add_parser("startproject")
291
+ project_parser.add_argument("name")
292
+ project_parser.add_argument("destination", nargs="?", default=".")
293
+ app_parser = subparsers.add_parser("startapp")
294
+ app_parser.add_argument("name")
295
+ app_parser.add_argument("--project", default=".")
296
+ run_parser = subparsers.add_parser("runserver")
297
+ run_parser.add_argument("--host", default="127.0.0.1")
298
+ run_parser.add_argument("--port", type=int, default=8000)
299
+ run_parser.add_argument("--project", default=None)
300
+ subparsers.add_parser("check")
301
+ subparsers.add_parser("routes")
302
+ user_parser = subparsers.add_parser("createsuperuser")
303
+ user_parser.add_argument("--email", required=True)
304
+ user_parser.add_argument("--project", default=None)
305
+ for command in (
306
+ "init",
307
+ "makemigrations",
308
+ "migrate",
309
+ "downgrade",
310
+ "history",
311
+ "heads",
312
+ "sqlmigrate",
313
+ ):
314
+ migration_parser = subparsers.add_parser(command)
315
+ migration_parser.add_argument("args", nargs=argparse.REMAINDER)
316
+
317
+ args = parser.parse_args(argv)
318
+ if args.command == "startproject":
319
+ startproject(args.name, args.destination)
320
+ return
321
+ if args.command == "startapp":
322
+ startapp(args.name, args.project)
323
+ return
324
+ if args.command == "runserver":
325
+ project = _validate_project_name(args.project or _project_name())
326
+ import uvicorn
327
+
328
+ uvicorn.run(f"{project}.main:app", host=args.host, port=args.port, reload=True)
329
+ return
330
+ if args.command in {
331
+ "init",
332
+ "makemigrations",
333
+ "migrate",
334
+ "downgrade",
335
+ "history",
336
+ "heads",
337
+ "sqlmigrate",
338
+ }:
339
+ raise SystemExit(_run_tortoise([args.command, *args.args]))
340
+ if args.command == "createsuperuser":
341
+ project = _validate_project_name(args.project or _project_name())
342
+ asyncio.run(_create_superuser(project, args.email))
343
+ return
344
+ project = _validate_project_name(_project_name())
345
+ if args.command == "check":
346
+ importlib.import_module(f"{project}.main")
347
+ print("System check identified no issues.")
348
+ return
349
+ if args.command == "routes":
350
+ app = importlib.import_module(f"{project}.main").app
351
+ for route in app.routes:
352
+ methods = ",".join(sorted(getattr(route, "methods", [])))
353
+ print(f"{getattr(route, 'path', ''):30} {methods}")
354
+
355
+
356
+ if __name__ == "__main__":
357
+ main()
358
+
drfast/filters.py ADDED
@@ -0,0 +1,36 @@
1
+ """Explicit filtering configuration for list endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from fastapi import HTTPException, Request, status
8
+
9
+
10
+ class FilterSet:
11
+ """Allowlist exact filters and explicitly declared Tortoise lookups."""
12
+
13
+ fields: ClassVar[dict[str, tuple[str, ...]] | tuple[str, ...]] = ()
14
+
15
+ @classmethod
16
+ def allowed_lookups(cls, name: str) -> tuple[str, ...]:
17
+ if isinstance(cls.fields, dict):
18
+ return cls.fields.get(name, ())
19
+ return ("exact",) if name in cls.fields else ()
20
+
21
+ @classmethod
22
+ def filters_from_request(cls, request: Request) -> dict[str, str]:
23
+ filters: dict[str, str] = {}
24
+ for name, value in request.query_params.multi_items():
25
+ if name in {"limit", "offset", "ordering"}:
26
+ continue
27
+ field, separator, lookup = name.partition("__")
28
+ lookup = lookup if separator else "exact"
29
+ if lookup not in cls.allowed_lookups(field):
30
+ raise HTTPException(
31
+ status_code=status.HTTP_400_BAD_REQUEST,
32
+ detail=f"Filtering by '{name}' is not allowed.",
33
+ )
34
+ filters[name] = value
35
+ return filters
36
+
drfast/middleware.py ADDED
@@ -0,0 +1,48 @@
1
+ """Class-based application middleware with deterministic hook ordering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import FastAPI, Request, Response
8
+
9
+
10
+ class BaseMiddleware:
11
+ """Implement any async hook; returning a response from request short-circuits."""
12
+
13
+ async def process_request(self, request: Request) -> Response | None:
14
+ return None
15
+
16
+ async def process_response(self, request: Request, response: Response) -> Response:
17
+ return response
18
+
19
+ async def process_exception(self, request: Request, exc: Exception) -> Response | None:
20
+ return None
21
+
22
+
23
+ def install_middleware(app: FastAPI, middleware_classes: list[type[BaseMiddleware]]) -> None:
24
+ """Install one ASGI-compatible pipeline around FastAPI routes.
25
+
26
+ Request hooks run in declaration order; response and exception hooks run in
27
+ reverse order, matching stack unwinding semantics.
28
+ """
29
+ instances = [middleware() for middleware in middleware_classes]
30
+
31
+ @app.middleware("http")
32
+ async def drfast_middleware(request: Request, call_next: Any) -> Response:
33
+ try:
34
+ for middleware in instances:
35
+ response = await middleware.process_request(request)
36
+ if response is not None:
37
+ return response
38
+ response = await call_next(request)
39
+ except Exception as exc:
40
+ for middleware in reversed(instances):
41
+ response = await middleware.process_exception(request, exc)
42
+ if response is not None:
43
+ return response
44
+ raise
45
+ for middleware in reversed(instances):
46
+ response = await middleware.process_response(request, response)
47
+ return response
48
+
drfast/models.py ADDED
@@ -0,0 +1,45 @@
1
+ """Tortoise model bases with explicit, composable conventions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tortoise import fields
6
+ from tortoise.models import Model as TortoiseModel
7
+
8
+
9
+ class Model(TortoiseModel):
10
+ """Abstract model with a UUID primary key."""
11
+
12
+ id = fields.UUIDField(primary_key=True)
13
+
14
+ class Meta:
15
+ abstract = True
16
+
17
+
18
+ class TimestampedModel(TortoiseModel):
19
+ """Opt-in UTC creation and modification timestamps."""
20
+
21
+ created_at = fields.DatetimeField(auto_now_add=True)
22
+ updated_at = fields.DatetimeField(auto_now=True)
23
+
24
+ class Meta:
25
+ abstract = True
26
+
27
+
28
+ class SoftDeleteModel(TortoiseModel):
29
+ """Opt-in soft deletion convention.
30
+
31
+ Applications choose whether ordinary queries exclude deleted rows; no manager
32
+ behavior is hidden by this mixin.
33
+ """
34
+
35
+ deleted_at = fields.DatetimeField(null=True)
36
+
37
+ class Meta:
38
+ abstract = True
39
+
40
+ async def soft_delete(self) -> None:
41
+ from datetime import UTC, datetime
42
+
43
+ self.deleted_at = datetime.now(UTC)
44
+ await self.save(update_fields=["deleted_at"])
45
+
drfast/orm.py ADDED
@@ -0,0 +1,35 @@
1
+ """FastAPI lifespan wiring for Tortoise ORM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from typing import Any
8
+
9
+ from fastapi import FastAPI
10
+ from tortoise.contrib.fastapi import RegisterTortoise
11
+
12
+ from drfast.settings import DRFastSettings
13
+
14
+
15
+ def configure_orm(app: FastAPI, settings: DRFastSettings) -> None:
16
+ """Attach a Tortoise lifespan to an application.
17
+
18
+ Schema generation is deliberately never enabled here; use migrations instead.
19
+ """
20
+ settings.validate_production()
21
+
22
+ @asynccontextmanager
23
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
24
+ async with RegisterTortoise(config=settings.tortoise_config()):
25
+ yield
26
+
27
+ app.router.lifespan_context = lifespan
28
+
29
+
30
+ def create_app(settings: DRFastSettings, **kwargs: Any) -> FastAPI:
31
+ """Create a configured FastAPI application without hiding FastAPI itself."""
32
+ app = FastAPI(title=settings.app_name, **kwargs)
33
+ configure_orm(app, settings)
34
+ return app
35
+