lenapi 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.
lenapi/application.py ADDED
@@ -0,0 +1,636 @@
1
+ """ASGI application, routers, and route dispatch."""
2
+
3
+ import inspect
4
+ from importlib import import_module
5
+ import math
6
+ import mimetypes
7
+ import re
8
+ import threading
9
+ import time
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, TypeAdapter, ValidationError
15
+ from sqlalchemy.exc import IntegrityError, MultipleResultsFound
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from .request import Request
19
+ from .response import HTTPException, JSONResponse, Response
20
+ from .dependencies import Depends
21
+ from .openapi import build_openapi, swagger_html
22
+ from .parameters import Body
23
+ from .database import AsyncDatabase, Database
24
+ from .mongodb import MongoDatabase
25
+ from .models import (
26
+ reset_current_async_session,
27
+ reset_current_session,
28
+ set_current_async_session,
29
+ set_current_session,
30
+ )
31
+ from .security_store import SecurityStoreUnavailable, create_security_store
32
+ from .background import BackgroundTasks
33
+ from .logging import get_logger
34
+ from .service import Cache, WebSocket, WebSocketDisconnect
35
+
36
+
37
+ logger = get_logger("websocket")
38
+
39
+
40
+ class Route:
41
+ def __init__(
42
+ self, path: str, methods: set[str], endpoint: Callable, response_model: Any = None,
43
+ *, api_flag: bool = False, cool_time: int = 300, request_body: dict[str, Any] | None = None,
44
+ ) -> None:
45
+ if cool_time <= 0:
46
+ raise ValueError("cool_time must be a positive number of seconds")
47
+ self.path = path
48
+ self.methods = methods
49
+ self.endpoint = endpoint
50
+ self.response_model = response_model
51
+ self.request_body = request_body
52
+ self.api_flag = api_flag
53
+ self.cool_time = cool_time
54
+ self.regex, self.parameter_names = self._compile_path(path)
55
+
56
+ @staticmethod
57
+ def _compile_path(path: str) -> tuple[re.Pattern, list[str]]:
58
+ names: list[str] = []
59
+ pattern = ""
60
+ cursor = 0
61
+ for match in re.finditer(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", path):
62
+ pattern += re.escape(path[cursor:match.start()]) + r"([^/]+)"
63
+ names.append(match.group(1))
64
+ cursor = match.end()
65
+ pattern += re.escape(path[cursor:])
66
+ return re.compile("^" + pattern + "$"), names
67
+
68
+ def match(self, path: str) -> dict[str, str] | None:
69
+ match = self.regex.match(path)
70
+ return None if match is None else dict(zip(self.parameter_names, match.groups()))
71
+
72
+
73
+ class Router:
74
+ """A group of routes that can be mounted on an Application or another Router."""
75
+
76
+ def __init__(self, *, prefix: str = "") -> None:
77
+ self.prefix = self._normalize_prefix(prefix)
78
+ self.routes: list[Route] = []
79
+ self.websocket_routes: list[Route] = []
80
+
81
+ @staticmethod
82
+ def _normalize_prefix(prefix: str) -> str:
83
+ if not prefix or prefix == "/":
84
+ return ""
85
+ return "/" + prefix.strip("/")
86
+
87
+ @staticmethod
88
+ def _normalize_path(path: str) -> str:
89
+ if not path:
90
+ return "/"
91
+ return "/" + path.strip("/") if path != "/" else "/"
92
+
93
+ def route(
94
+ self, path: str, *, methods: tuple[str, ...] = ("GET",), response_model: Any = None,
95
+ api_flag: bool = False, cool_time: int = 300,
96
+ request_body: dict[str, Any] | None = None,
97
+ ):
98
+ def decorator(endpoint: Callable):
99
+ self.routes.append(Route(
100
+ self.prefix + self._normalize_path(path), {method.upper() for method in methods},
101
+ endpoint, response_model, api_flag=api_flag, cool_time=cool_time,
102
+ request_body=request_body,
103
+ ))
104
+ return endpoint
105
+ return decorator
106
+
107
+ def get(self, path: str, *, response_model: Any = None, api_flag: bool = False, cool_time: int = 300):
108
+ return self.route(path, methods=("GET",), response_model=response_model, api_flag=api_flag, cool_time=cool_time)
109
+
110
+ def post(self, path: str, *, response_model: Any = None, api_flag: bool = False, cool_time: int = 300,
111
+ request_body: dict[str, Any] | None = None):
112
+ return self.route(path, methods=("POST",), response_model=response_model, api_flag=api_flag,
113
+ cool_time=cool_time, request_body=request_body)
114
+
115
+ def put(self, path: str, *, response_model: Any = None, api_flag: bool = False, cool_time: int = 300):
116
+ return self.route(path, methods=("PUT",), response_model=response_model, api_flag=api_flag, cool_time=cool_time)
117
+
118
+ def delete(self, path: str, *, response_model: Any = None, api_flag: bool = False, cool_time: int = 300):
119
+ return self.route(path, methods=("DELETE",), response_model=response_model, api_flag=api_flag, cool_time=cool_time)
120
+
121
+ def websocket(self, path: str):
122
+ """Register an ASGI WebSocket handler on this router."""
123
+ def decorator(endpoint: Callable):
124
+ self.websocket_routes.append(Route(
125
+ self.prefix + self._normalize_path(path), {"WEBSOCKET"}, endpoint
126
+ ))
127
+ return endpoint
128
+ return decorator
129
+
130
+ def include_router(self, router: "Router", *, prefix: str = "") -> None:
131
+ mount_prefix = self._normalize_prefix(prefix)
132
+ for route in router.routes:
133
+ self.routes.append(Route(
134
+ mount_prefix + route.path, route.methods, route.endpoint, route.response_model,
135
+ api_flag=route.api_flag, cool_time=route.cool_time,
136
+ request_body=route.request_body,
137
+ ))
138
+ for route in router.websocket_routes:
139
+ self.websocket_routes.append(Route(
140
+ mount_prefix + route.path, {"WEBSOCKET"}, route.endpoint
141
+ ))
142
+
143
+ def add_router(self, prefix: str, router: "Router") -> None:
144
+ """Mount a router using Django Ninja style: ``app.add_router('/users/', router)``."""
145
+ self.include_router(router, prefix=prefix)
146
+
147
+
148
+ class Application(Router):
149
+ """An ASGI app with Pydantic validation and optional SQLAlchemy Session injection."""
150
+
151
+ def __init__(self, *, session_factory: Callable[[], Any] | None = None, database: Any = None,
152
+ async_database: AsyncDatabase | None = None,
153
+ mongodb: MongoDatabase | None = None,
154
+ title: str = "lenApi", version: str = "0.1.0",
155
+ docs_url: str | None = "/docs/", openapi_url: str | None = "/openapi.json",
156
+ max_request_body_size: int | None = 10 * 1024 * 1024,
157
+ request_body_timeout: float | None = 30.0,
158
+ ip_flag_backend: str | None = None, redis_url: str | None = None,
159
+ security_storage: str | None = None,
160
+ cache_backend: str = "memory",
161
+ media_root: str | Path | None = None, media_url: str | None = None) -> None:
162
+ if session_factory is not None and database is not None:
163
+ raise ValueError("Pass either session_factory or database, not both")
164
+ if max_request_body_size is not None and (
165
+ isinstance(max_request_body_size, bool) or not isinstance(max_request_body_size, int)
166
+ or max_request_body_size < 0
167
+ ):
168
+ raise ValueError("max_request_body_size must be a non-negative integer or None")
169
+ if request_body_timeout is not None and (
170
+ isinstance(request_body_timeout, bool) or not isinstance(request_body_timeout, (int, float))
171
+ or request_body_timeout <= 0
172
+ ):
173
+ raise ValueError("request_body_timeout must be a positive number or None")
174
+ super().__init__()
175
+ self.database = database if database is not None else (
176
+ None if session_factory is not None or async_database is not None else Database()
177
+ )
178
+ self.session_factory = session_factory or (self.database.session_factory if self.database is not None else None)
179
+ self.async_database = async_database
180
+ self.async_session_factory = async_database.session_factory if async_database is not None else None
181
+ self.mongodb = mongodb
182
+ self.title = title
183
+ self.version = version
184
+ self.docs_url = docs_url
185
+ self.openapi_url = openapi_url
186
+ self.media_root = Path(media_root).expanduser().resolve() if media_root is not None else None
187
+ self.media_url = self._normalize_media_url(media_url) if self.media_root is not None and media_url else None
188
+ self.max_request_body_size = max_request_body_size
189
+ self.request_body_timeout = float(request_body_timeout) if request_body_timeout is not None else None
190
+ self._throttle_until: dict[tuple[str, str], float] = {}
191
+ self._throttle_lock = threading.Lock()
192
+ self.user_middleware: list[tuple[type, dict[str, Any]]] = []
193
+ self._middleware_stack = None
194
+ self.security_storage = (security_storage or ip_flag_backend or "memory").lower()
195
+ self.security_store = create_security_store(
196
+ self.security_storage,
197
+ engine=self.database.engine if self.database is not None else None,
198
+ redis_url=redis_url,
199
+ )
200
+ self.ip_flag_backend = self.security_storage
201
+ self.cache = Cache(
202
+ cache_backend,
203
+ engine=self.database.engine if self.database is not None else None,
204
+ redis_url=redis_url,
205
+ )
206
+
207
+ def add_middleware(self, middleware_class, **options) -> None:
208
+ """Add ASGI middleware, for example ``app.add_middleware(CORSMiddleware, ...)``.
209
+
210
+ Middleware is wrapped in the order it is added; the first added is outermost.
211
+ """
212
+ if not callable(middleware_class):
213
+ raise TypeError("middleware_class must be callable")
214
+ self.user_middleware.append((middleware_class, options))
215
+ self._middleware_stack = None
216
+
217
+ async def __call__(self, scope, receive, send) -> None:
218
+ if self._middleware_stack is None:
219
+ app = self._dispatch
220
+ for middleware_class, options in reversed(self.user_middleware):
221
+ app = middleware_class(app, **options)
222
+ self._middleware_stack = app
223
+ await self._middleware_stack(scope, receive, send)
224
+
225
+ async def _dispatch(self, scope, receive, send) -> None:
226
+ if scope["type"] == "lifespan":
227
+ await self._handle_lifespan(receive, send)
228
+ return
229
+ if scope["type"] == "websocket":
230
+ await self._handle_websocket(scope, receive, send)
231
+ return
232
+ if scope["type"] != "http":
233
+ return
234
+ path, method = scope.get("path", "/"), scope.get("method", "GET").upper()
235
+ route_scope = scope
236
+ if self.docs_url is not None and self.openapi_url is not None and path in {self.docs_url, self.docs_url.rstrip("/")} and method == "GET":
237
+ response = Response(swagger_html(self.openapi_url), media_type="text/html; charset=utf-8")
238
+ await response(route_scope, receive, send)
239
+ return
240
+ if self.openapi_url is not None and path == self.openapi_url and method == "GET":
241
+ response = JSONResponse(build_openapi(self, title=self.title, version=self.version))
242
+ await response(route_scope, receive, send)
243
+ return
244
+ if self.media_url is not None and path.startswith(self.media_url) and method == "GET":
245
+ relative_path = path[len(self.media_url):]
246
+ response = self._media_response(relative_path)
247
+ await response(route_scope, receive, send)
248
+ return
249
+ path_matched = False
250
+ for route in self.routes:
251
+ raw_params = route.match(path)
252
+ if raw_params is None:
253
+ continue
254
+ path_matched = True
255
+ if method not in route.methods:
256
+ continue
257
+ if self.max_request_body_size is not None:
258
+ raw_length = next(
259
+ (value for name, value in scope.get("headers", []) if name.lower() == b"content-length"),
260
+ None,
261
+ )
262
+ if raw_length is not None:
263
+ try:
264
+ content_length = int(raw_length)
265
+ except ValueError:
266
+ response = JSONResponse({"detail": "Invalid Content-Length"}, status_code=400)
267
+ await response(scope, receive, send)
268
+ return
269
+ if content_length < 0:
270
+ response = JSONResponse({"detail": "Invalid Content-Length"}, status_code=400)
271
+ await response(scope, receive, send)
272
+ return
273
+ if content_length > self.max_request_body_size:
274
+ response = JSONResponse({"detail": "Request body too large"}, status_code=413)
275
+ await response(scope, receive, send)
276
+ return
277
+ if route.api_flag:
278
+ client = scope.get("client")
279
+ client_ip = str(client[0]) if client else "unknown"
280
+ try:
281
+ retry_after = await self._claim_ip_flag(route.path, client_ip, route.cool_time)
282
+ except Exception:
283
+ hint = (
284
+ "Check REDIS_URL and Redis availability."
285
+ if self.ip_flag_backend == "redis"
286
+ else "Run `python lenora.py migration` to create or update the security store table."
287
+ )
288
+ response = JSONResponse(
289
+ {"detail": f"IP flag storage is unavailable. {hint}"}, status_code=503
290
+ )
291
+ await response(scope, receive, send)
292
+ return
293
+ if retry_after > 0:
294
+ response = JSONResponse(
295
+ {"detail": "Too many requests", "cool_time": route.cool_time},
296
+ status_code=429,
297
+ headers={"Retry-After": str(max(1, math.ceil(retry_after)))},
298
+ )
299
+ await response(scope, receive, send)
300
+ return
301
+ route_scope = {**scope, "path_params": raw_params}
302
+ request = Request(
303
+ route_scope,
304
+ receive,
305
+ max_body_size=self.max_request_body_size,
306
+ body_timeout=self.request_body_timeout,
307
+ )
308
+ request.background_tasks = BackgroundTasks()
309
+ session = None
310
+ async_session = None
311
+ session_token = None
312
+ async_session_token = None
313
+ try:
314
+ if self.session_factory is not None:
315
+ session = self.session_factory()
316
+ session_token = set_current_session(session)
317
+ if self.async_session_factory is not None:
318
+ async_session = self.async_session_factory()
319
+ async_session_token = set_current_async_session(async_session)
320
+ kwargs = await self._build_arguments(
321
+ route.endpoint, request, raw_params, session, {}, set(), async_session
322
+ )
323
+ result = route.endpoint(**kwargs)
324
+ if inspect.isawaitable(result):
325
+ result = await result
326
+ if isinstance(result, Response):
327
+ response = result
328
+ else:
329
+ if route.response_model is not None:
330
+ try:
331
+ result = TypeAdapter(route.response_model).validate_python(
332
+ result, from_attributes=True
333
+ )
334
+ result = TypeAdapter(route.response_model).dump_python(result, mode="json")
335
+ except ValidationError as exc:
336
+ response = JSONResponse({"detail": "Invalid response data"}, status_code=500)
337
+ await response(route_scope, receive, send)
338
+
339
+ return
340
+ response = JSONResponse(result)
341
+ except ValidationError:
342
+ response = JSONResponse({"error": "Invalid request"}, status_code=422)
343
+ except MultipleResultsFound:
344
+ response = JSONResponse({"error": "Multiple entries found"}, status_code=409)
345
+ except IntegrityError:
346
+ response = JSONResponse({"error": "Duplicate or conflicting data"}, status_code=409)
347
+ except HTTPException as exc:
348
+ response = JSONResponse(
349
+ {"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers
350
+ )
351
+ except SecurityStoreUnavailable:
352
+ hint = (
353
+ "Run `python lenora.py migration` to create or update the security store table."
354
+ if self.security_storage == "database"
355
+ else "Install `lenapi[redis]` and check REDIS_URL and Redis availability."
356
+ )
357
+ response = JSONResponse(
358
+ {"detail": f"Security storage is unavailable. {hint}"}, status_code=503
359
+ )
360
+ except (TypeError, ValueError, KeyError):
361
+ response = JSONResponse({"error": "Invalid request"}, status_code=422)
362
+ finally:
363
+ if session_token is not None:
364
+ reset_current_session(session_token)
365
+ if async_session_token is not None:
366
+ reset_current_async_session(async_session_token)
367
+ if session is not None:
368
+ session.close()
369
+ if async_session is not None:
370
+ await async_session.close()
371
+ await response(route_scope, receive, send)
372
+ if response.status_code < 400:
373
+ await request.background_tasks.run()
374
+ return
375
+ response = JSONResponse({"detail": "Method not allowed" if path_matched else "Not found"},
376
+ status_code=405 if path_matched else 404)
377
+ await response(scope, receive, send)
378
+
379
+ async def _handle_websocket(self, scope, receive, send) -> None:
380
+ initial = await receive()
381
+ if initial.get("type") != "websocket.connect":
382
+ await send({"type": "websocket.close", "code": 1002, "reason": "Expected websocket.connect"})
383
+ return
384
+
385
+ path = scope.get("path", "/")
386
+ for route in self.websocket_routes:
387
+ path_params = route.match(path)
388
+ if path_params is None:
389
+ continue
390
+ websocket = WebSocket(scope, receive, send, path_params=path_params)
391
+ try:
392
+ kwargs: dict[str, Any] = {}
393
+ for name, parameter in inspect.signature(route.endpoint).parameters.items():
394
+ annotation = parameter.annotation
395
+ if annotation is WebSocket or name in {"websocket", "ws"}:
396
+ kwargs[name] = websocket
397
+ elif name in path_params:
398
+ raw = path_params[name]
399
+ kwargs[name] = raw if annotation is inspect.Parameter.empty else TypeAdapter(annotation).validate_python(raw)
400
+ elif parameter.default is inspect.Parameter.empty:
401
+ raise ValueError(f"Missing WebSocket parameter: {name}")
402
+ result = route.endpoint(**kwargs)
403
+ if inspect.isawaitable(result):
404
+ await result
405
+ except WebSocketDisconnect:
406
+ pass
407
+ except Exception:
408
+ logger.exception("WebSocket handler failed for %s", path)
409
+ if not websocket.closed:
410
+ await websocket.close(code=1011, reason="Internal server error")
411
+ else:
412
+ if not websocket.closed:
413
+ await websocket.close()
414
+ return
415
+
416
+ await send({"type": "websocket.close", "code": 4404, "reason": "WebSocket route not found"})
417
+
418
+ @staticmethod
419
+ def _normalize_media_url(media_url: str) -> str:
420
+ if not media_url.startswith("/"):
421
+ media_url = "/" + media_url
422
+ media_url = media_url.rstrip("/") + "/"
423
+ if ".." in media_url.split("/"):
424
+ raise ValueError("MEDIA_URL cannot contain '..' path segments")
425
+ return media_url
426
+
427
+ def _media_response(self, relative_path: str) -> Response:
428
+ if self.media_root is None or not relative_path:
429
+ return Response(b"Not found", status_code=404)
430
+ root = self.media_root
431
+ file_path = (root / relative_path).resolve()
432
+ if not file_path.is_relative_to(root) or not file_path.is_file():
433
+ return Response(b"Not found", status_code=404)
434
+ media_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
435
+ return Response(file_path.read_bytes(), media_type=media_type)
436
+
437
+ async def _handle_lifespan(self, receive, send) -> None:
438
+ while True:
439
+ message = await receive()
440
+ if message["type"] == "lifespan.startup":
441
+ await send({"type": "lifespan.startup.complete"})
442
+ elif message["type"] == "lifespan.shutdown":
443
+ if self.security_storage == "redis":
444
+ await self.security_store.close()
445
+ await self.cache.close()
446
+ if self.database is not None:
447
+ self.database.close()
448
+ if self.async_database is not None:
449
+ await self.async_database.close()
450
+ if self.mongodb is not None:
451
+ await self.mongodb.aclose()
452
+ await send({"type": "lifespan.shutdown.complete"})
453
+
454
+ async def _claim_ip_flag(self, route: str, client_ip: str, cool_time: int) -> int:
455
+ if self.security_storage in {"database", "redis"}:
456
+ return await self.security_store.claim(f"ip_flag:{route}:{client_ip}", cool_time)
457
+ key = (route, client_ip)
458
+ now = time.monotonic()
459
+ with self._throttle_lock:
460
+ if len(self._throttle_until) > 4096:
461
+ self._throttle_until = {
462
+ item: expiry for item, expiry in self._throttle_until.items() if expiry > now
463
+ }
464
+ retry_after = self._throttle_until.get(key, 0.0) - now
465
+ if retry_after <= 0:
466
+ self._throttle_until[key] = now + cool_time
467
+ return 0
468
+ return math.ceil(retry_after)
469
+
470
+
471
+ @staticmethod
472
+ def _needs_session(endpoint: Callable) -> bool:
473
+ try:
474
+ from sqlalchemy.orm import Session
475
+ except ImportError:
476
+ return False
477
+ def visit(callable_: Callable, seen: set[Callable]) -> bool:
478
+ if callable_ in seen:
479
+ return False
480
+ seen.add(callable_)
481
+ for parameter in inspect.signature(callable_).parameters.values():
482
+ if parameter.annotation is Session:
483
+ return True
484
+ if isinstance(parameter.default, Depends) and visit(parameter.default.dependency, seen):
485
+ return True
486
+ return False
487
+ return visit(endpoint, set())
488
+
489
+ @staticmethod
490
+ def _needs_async_session(endpoint: Callable) -> bool:
491
+ def visit(callable_: Callable, seen: set[Callable]) -> bool:
492
+ if callable_ in seen:
493
+ return False
494
+ seen.add(callable_)
495
+ for parameter in inspect.signature(callable_).parameters.values():
496
+ if parameter.annotation is AsyncSession or (
497
+ isinstance(parameter.annotation, str)
498
+ and parameter.annotation in {"AsyncSession", "sqlalchemy.ext.asyncio.session.AsyncSession"}
499
+ ):
500
+ return True
501
+ if isinstance(parameter.default, Depends) and visit(parameter.default.dependency, seen):
502
+ return True
503
+ return False
504
+ return visit(endpoint, set())
505
+
506
+ @classmethod
507
+ async def _build_arguments(cls, endpoint: Callable, request: Request, path_params: dict[str, str], session: Any = None,
508
+ cache: dict[Callable, Any] | None = None, resolving: set[Callable] | None = None,
509
+ async_session: AsyncSession | None = None) -> dict[str, Any]:
510
+ cache = {} if cache is None else cache
511
+ resolving = set() if resolving is None else resolving
512
+ values: dict[str, Any] = {}
513
+ query = request.query_params
514
+ body_loaded = False
515
+ body: Any = None
516
+ for name, parameter in inspect.signature(endpoint).parameters.items():
517
+ annotation = parameter.annotation
518
+ if isinstance(parameter.default, Body):
519
+ values[name] = await request.json()
520
+ continue
521
+ if isinstance(parameter.default, Depends):
522
+ dependency = parameter.default.dependency
523
+ if dependency in resolving:
524
+ raise ValueError(f"Circular dependency detected: {dependency.__name__}")
525
+ if dependency not in cache:
526
+ resolving.add(dependency)
527
+ dependency_kwargs = await cls._build_arguments(
528
+ dependency, request, path_params, session, cache, resolving, async_session
529
+ )
530
+ result = dependency(**dependency_kwargs)
531
+ if inspect.isawaitable(result):
532
+ result = await result
533
+ cache[dependency] = result
534
+ resolving.remove(dependency)
535
+ values[name] = cache[dependency]
536
+ continue
537
+ if name == "request" and annotation in (Request, inspect.Parameter.empty):
538
+ values[name] = request
539
+ continue
540
+ if annotation is BackgroundTasks:
541
+ values[name] = request.background_tasks
542
+ continue
543
+ if session is not None and annotation.__module__.startswith("sqlalchemy") and annotation.__name__ == "Session":
544
+ values[name] = session
545
+ continue
546
+ if async_session is not None and (
547
+ annotation is AsyncSession or (
548
+ isinstance(annotation, str)
549
+ and annotation in {"AsyncSession", "sqlalchemy.ext.asyncio.session.AsyncSession"}
550
+ )
551
+ ):
552
+ values[name] = async_session
553
+ continue
554
+ if inspect.isclass(annotation) and issubclass(annotation, BaseModel):
555
+ if not body_loaded:
556
+ body = await request.json()
557
+ body_loaded = True
558
+ values[name] = annotation.model_validate(body)
559
+ continue
560
+ raw = path_params.get(name, query.get(name, inspect.Parameter.empty))
561
+ if raw is inspect.Parameter.empty:
562
+ if parameter.default is not inspect.Parameter.empty:
563
+ continue
564
+ raise ValueError(f"Missing required parameter: {name}")
565
+ if isinstance(raw, list):
566
+ raise ValueError(f"Parameter '{name}' may only be supplied once")
567
+ values[name] = raw if annotation is inspect.Parameter.empty else TypeAdapter(annotation).validate_python(raw)
568
+ return values
569
+
570
+
571
+ class LenApi(Application):
572
+ """Application configured from the current project's optional settings.py."""
573
+
574
+ def __init__(self, *, session_factory: Callable[[], Any] | None = None, database: Any = None,
575
+ async_database: AsyncDatabase | None = None,
576
+ mongodb: MongoDatabase | None = None,
577
+ title: str | None = None, version: str | None = None,
578
+ docs_url: str | None = None, openapi_url: str | None = None,
579
+ max_request_body_size: int | None = None,
580
+ request_body_timeout: float | None = None,
581
+ ip_flag_backend: str | None = None, redis_url: str | None = None,
582
+ security_storage: str | None = None,
583
+ cache_backend: str | None = None,
584
+ media_root: str | Path | None = None, media_url: str | None = None) -> None:
585
+ # Project settings are optional, so importing lenapi outside a project
586
+ # continues to work with the framework defaults.
587
+ try:
588
+ project_settings = import_module("settings")
589
+ except ModuleNotFoundError as exc:
590
+ if exc.name != "settings":
591
+ raise
592
+ project_settings = None
593
+
594
+ configured_database = database
595
+ if configured_database is None and session_factory is None and project_settings is not None:
596
+ database_url = getattr(project_settings, "DATABASE_URL", None)
597
+ if database_url:
598
+ configured_database = Database(database_url)
599
+
600
+ configured_async_database = async_database
601
+ if configured_async_database is None and project_settings is not None:
602
+ async_database_url = getattr(project_settings, "ASYNC_DATABASE_URL", None)
603
+ if async_database_url:
604
+ configured_async_database = AsyncDatabase(async_database_url)
605
+
606
+ configured_mongodb = mongodb
607
+ if configured_mongodb is None and project_settings is not None:
608
+ mongodb_uri = getattr(project_settings, "MONGODB_URI", None)
609
+ mongodb_name = getattr(project_settings, "MONGODB_DATABASE", None)
610
+ if mongodb_uri and mongodb_name:
611
+ configured_mongodb = MongoDatabase(mongodb_uri, mongodb_name)
612
+
613
+ super().__init__(
614
+ session_factory=session_factory,
615
+ database=configured_database,
616
+ async_database=configured_async_database,
617
+ mongodb=configured_mongodb,
618
+ title=title or getattr(project_settings, "API_TITLE", "lenApi"),
619
+ version=version or getattr(project_settings, "API_VERSION", "0.1.0"),
620
+ docs_url=docs_url if docs_url is not None else getattr(project_settings, "DOCS_URL", "/docs/"),
621
+ openapi_url=openapi_url if openapi_url is not None else getattr(project_settings, "OPENAPI_URL", "/openapi.json"),
622
+ max_request_body_size=(max_request_body_size if max_request_body_size is not None else
623
+ getattr(project_settings, "MAX_REQUEST_BODY_SIZE", 10 * 1024 * 1024)),
624
+ request_body_timeout=(request_body_timeout if request_body_timeout is not None else
625
+ getattr(project_settings, "REQUEST_BODY_TIMEOUT_SECONDS", 30.0)),
626
+ ip_flag_backend=(ip_flag_backend if ip_flag_backend is not None else
627
+ getattr(project_settings, "IP_FLAG_BACKEND", None)),
628
+ redis_url=redis_url if redis_url is not None else getattr(
629
+ project_settings, "REDIS_URL", "redis://127.0.0.1:6379/0"
630
+ ),
631
+ security_storage=(security_storage if security_storage is not None else
632
+ getattr(project_settings, "SECURITY_STORAGE", None)),
633
+ cache_backend=cache_backend or getattr(project_settings, "CACHE_BACKEND", "memory"),
634
+ media_root=media_root if media_root is not None else getattr(project_settings, "MEDIA_ROOT", None),
635
+ media_url=media_url if media_url is not None else getattr(project_settings, "MEDIA_URL", None),
636
+ )