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/__init__.py +79 -0
- lenapi/application.py +636 -0
- lenapi/authorization.py +96 -0
- lenapi/background.py +395 -0
- lenapi/cli.py +731 -0
- lenapi/database.py +84 -0
- lenapi/dependencies.py +12 -0
- lenapi/logging.py +13 -0
- lenapi/middleware.py +144 -0
- lenapi/model.py +54 -0
- lenapi/models.py +1052 -0
- lenapi/mongodb.py +262 -0
- lenapi/openapi.py +181 -0
- lenapi/parameters.py +8 -0
- lenapi/payments.py +543 -0
- lenapi/request.py +177 -0
- lenapi/response.py +91 -0
- lenapi/security.py +665 -0
- lenapi/security_store.py +193 -0
- lenapi/service.py +350 -0
- lenapi-0.1.0.dist-info/METADATA +959 -0
- lenapi-0.1.0.dist-info/RECORD +25 -0
- lenapi-0.1.0.dist-info/WHEEL +5 -0
- lenapi-0.1.0.dist-info/entry_points.txt +2 -0
- lenapi-0.1.0.dist-info/top_level.txt +1 -0
lenapi/database.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""SQLAlchemy engine and session setup for lenApi applications."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Engine, create_engine, event
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
9
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Database:
|
|
13
|
+
"""Own a SQLAlchemy engine and create one sync Session per request.
|
|
14
|
+
|
|
15
|
+
SQLAlchemy selects the database dialect from the URL. Install the matching
|
|
16
|
+
DBAPI driver for MySQL or PostgreSQL; SQLite uses Python's built-in driver.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, url: str = "sqlite:///lenapi.db", *, engine_options: Mapping[str, Any] | None = None) -> None:
|
|
20
|
+
options = {"pool_pre_ping": True, **(engine_options or {})}
|
|
21
|
+
self.engine: Engine = create_engine(url, **options)
|
|
22
|
+
if self.engine.dialect.name == "sqlite":
|
|
23
|
+
@event.listens_for(self.engine, "connect")
|
|
24
|
+
def enable_sqlite_foreign_keys(connection, _record):
|
|
25
|
+
cursor = connection.cursor()
|
|
26
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
27
|
+
cursor.close()
|
|
28
|
+
self.session_factory = sessionmaker(bind=self.engine, class_=Session)
|
|
29
|
+
if self.engine.dialect.name == "sqlite":
|
|
30
|
+
# SQLite creates a file on first connection; create it with Database().
|
|
31
|
+
with self.engine.connect():
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def session(self) -> Session:
|
|
35
|
+
"""Create a session for manual use outside request handlers."""
|
|
36
|
+
return self.session_factory()
|
|
37
|
+
|
|
38
|
+
@contextmanager
|
|
39
|
+
def transaction(self):
|
|
40
|
+
"""Yield a session that commits on success and rolls back on error.
|
|
41
|
+
|
|
42
|
+
Use the yielded session for all related SQLAlchemy operations inside the
|
|
43
|
+
block; do not call ``commit`` manually.
|
|
44
|
+
"""
|
|
45
|
+
session = self.session_factory()
|
|
46
|
+
try:
|
|
47
|
+
with session.begin():
|
|
48
|
+
yield session
|
|
49
|
+
finally:
|
|
50
|
+
session.close()
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
"""Dispose the engine's connection pool during application shutdown."""
|
|
54
|
+
self.engine.dispose()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AsyncDatabase:
|
|
58
|
+
"""Async SQLAlchemy engine and request-scoped ``AsyncSession`` factory.
|
|
59
|
+
|
|
60
|
+
Install the driver for the chosen database, for example ``aiosqlite`` for
|
|
61
|
+
SQLite, ``asyncpg`` for PostgreSQL, or ``asyncmy`` for MySQL.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, url: str, *, engine_options: Mapping[str, Any] | None = None) -> None:
|
|
65
|
+
options = {"pool_pre_ping": True, **(engine_options or {})}
|
|
66
|
+
self.engine: AsyncEngine = create_async_engine(url, **options)
|
|
67
|
+
self.session_factory = async_sessionmaker(
|
|
68
|
+
bind=self.engine, class_=AsyncSession, expire_on_commit=False
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def session(self) -> AsyncSession:
|
|
72
|
+
"""Create an async session for use with ``async with``."""
|
|
73
|
+
return self.session_factory()
|
|
74
|
+
|
|
75
|
+
@asynccontextmanager
|
|
76
|
+
async def transaction(self):
|
|
77
|
+
"""Yield an AsyncSession; commit on success and roll back on error."""
|
|
78
|
+
async with self.session_factory() as session:
|
|
79
|
+
async with session.begin():
|
|
80
|
+
yield session
|
|
81
|
+
|
|
82
|
+
async def close(self) -> None:
|
|
83
|
+
"""Dispose the async connection pool during application shutdown."""
|
|
84
|
+
await self.engine.dispose()
|
lenapi/dependencies.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Dependency declaration helpers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Depends:
|
|
10
|
+
"""Mark a parameter for resolution from another callable."""
|
|
11
|
+
|
|
12
|
+
dependency: Callable[..., Any]
|
lenapi/logging.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Public standard-library loggers for lenApi applications."""
|
|
2
|
+
|
|
3
|
+
import logging as _logging
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
logger = _logging.getLogger("lenapi")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_logger(name: str | None = None) -> _logging.Logger:
|
|
10
|
+
"""Return lenApi's default logger or a named child logger."""
|
|
11
|
+
if not name:
|
|
12
|
+
return logger
|
|
13
|
+
return _logging.getLogger(f"lenapi.{name}")
|
lenapi/middleware.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Small ASGI middleware components for lenApi applications."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RequestIDMiddleware:
|
|
9
|
+
"""Attach a safe request ID to ASGI state and the response headers."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, app, *, header_name: str = "X-Request-ID") -> None:
|
|
12
|
+
self.app = app
|
|
13
|
+
self.header_name = header_name.encode("latin-1").lower()
|
|
14
|
+
|
|
15
|
+
async def __call__(self, scope, receive, send):
|
|
16
|
+
if scope["type"] != "http":
|
|
17
|
+
return await self.app(scope, receive, send)
|
|
18
|
+
supplied = next(
|
|
19
|
+
(value.decode("latin-1") for name, value in scope.get("headers", [])
|
|
20
|
+
if name.lower() == self.header_name),
|
|
21
|
+
"",
|
|
22
|
+
)
|
|
23
|
+
if not supplied or len(supplied) > 128 or any(
|
|
24
|
+
not (character.isalnum() or character in "._-") for character in supplied
|
|
25
|
+
):
|
|
26
|
+
supplied = secrets.token_hex(16)
|
|
27
|
+
scope.setdefault("state", {})["request_id"] = supplied
|
|
28
|
+
|
|
29
|
+
async def send_with_id(message):
|
|
30
|
+
if message["type"] == "http.response.start":
|
|
31
|
+
headers = list(message.get("headers", []))
|
|
32
|
+
headers.append((self.header_name, supplied.encode("latin-1")))
|
|
33
|
+
message = {**message, "headers": headers}
|
|
34
|
+
await send(message)
|
|
35
|
+
|
|
36
|
+
await self.app(scope, receive, send_with_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TimingMiddleware:
|
|
40
|
+
"""Add elapsed request time as an ``X-Process-Time-Ms`` response header."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, app, *, header_name: str = "X-Process-Time-Ms") -> None:
|
|
43
|
+
self.app = app
|
|
44
|
+
self.header_name = header_name.encode("latin-1").lower()
|
|
45
|
+
|
|
46
|
+
async def __call__(self, scope, receive, send):
|
|
47
|
+
if scope["type"] != "http":
|
|
48
|
+
return await self.app(scope, receive, send)
|
|
49
|
+
started = time.perf_counter()
|
|
50
|
+
|
|
51
|
+
async def send_with_timing(message):
|
|
52
|
+
if message["type"] == "http.response.start":
|
|
53
|
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
54
|
+
headers = list(message.get("headers", []))
|
|
55
|
+
headers.append((self.header_name, f"{elapsed_ms:.3f}".encode("ascii")))
|
|
56
|
+
message = {**message, "headers": headers}
|
|
57
|
+
await send(message)
|
|
58
|
+
|
|
59
|
+
await self.app(scope, receive, send_with_timing)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CORSMiddleware:
|
|
63
|
+
"""Configurable CORS handling for browser clients."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
app,
|
|
68
|
+
*,
|
|
69
|
+
allow_origins: Iterable[str],
|
|
70
|
+
allow_methods: Iterable[str] = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"),
|
|
71
|
+
allow_headers: Iterable[str] = ("*",),
|
|
72
|
+
allow_credentials: bool = False,
|
|
73
|
+
expose_headers: Iterable[str] = (),
|
|
74
|
+
max_age: int = 600,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.app = app
|
|
77
|
+
self.allow_origins = set(allow_origins)
|
|
78
|
+
self.allow_methods = {method.upper() for method in allow_methods}
|
|
79
|
+
self.allow_headers = {header.lower() for header in allow_headers}
|
|
80
|
+
self.allow_credentials = allow_credentials
|
|
81
|
+
self.expose_headers = tuple(expose_headers)
|
|
82
|
+
self.max_age = max_age
|
|
83
|
+
if "*" in self.allow_origins and allow_credentials:
|
|
84
|
+
raise ValueError("CORS wildcard origins cannot be combined with credentials")
|
|
85
|
+
if isinstance(max_age, bool) or max_age < 0:
|
|
86
|
+
raise ValueError("CORS max_age must be a non-negative integer")
|
|
87
|
+
|
|
88
|
+
def _origin_headers(self, origin: str) -> list[tuple[bytes, bytes]]:
|
|
89
|
+
allowed = "*" in self.allow_origins or origin in self.allow_origins
|
|
90
|
+
if not allowed:
|
|
91
|
+
return []
|
|
92
|
+
result = [(b"access-control-allow-origin", (origin if self.allow_credentials else
|
|
93
|
+
("*" if "*" in self.allow_origins else origin)).encode())]
|
|
94
|
+
result.append((b"vary", b"Origin"))
|
|
95
|
+
if self.allow_credentials:
|
|
96
|
+
result.append((b"access-control-allow-credentials", b"true"))
|
|
97
|
+
if self.expose_headers:
|
|
98
|
+
result.append((b"access-control-expose-headers", ", ".join(self.expose_headers).encode()))
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
async def __call__(self, scope, receive, send):
|
|
102
|
+
if scope["type"] != "http":
|
|
103
|
+
return await self.app(scope, receive, send)
|
|
104
|
+
headers = {name.lower(): value for name, value in scope.get("headers", [])}
|
|
105
|
+
origin = headers.get(b"origin", b"").decode("latin-1")
|
|
106
|
+
if not origin:
|
|
107
|
+
return await self.app(scope, receive, send)
|
|
108
|
+
cors_headers = self._origin_headers(origin)
|
|
109
|
+
if not cors_headers:
|
|
110
|
+
return await self.app(scope, receive, send)
|
|
111
|
+
|
|
112
|
+
is_preflight = scope.get("method", "GET").upper() == "OPTIONS" and b"access-control-request-method" in headers
|
|
113
|
+
if is_preflight:
|
|
114
|
+
requested_method = headers[b"access-control-request-method"].decode("latin-1").upper()
|
|
115
|
+
requested_headers = [
|
|
116
|
+
name.strip().lower()
|
|
117
|
+
for name in headers.get(b"access-control-request-headers", b"").decode("latin-1").split(",")
|
|
118
|
+
if name.strip()
|
|
119
|
+
]
|
|
120
|
+
if requested_method not in self.allow_methods or (
|
|
121
|
+
"*" not in self.allow_headers and any(name not in self.allow_headers for name in requested_headers)
|
|
122
|
+
):
|
|
123
|
+
await send({"type": "http.response.start", "status": 400,
|
|
124
|
+
"headers": [(b"content-length", b"0")]})
|
|
125
|
+
await send({"type": "http.response.body", "body": b""})
|
|
126
|
+
return
|
|
127
|
+
cors_headers.extend([
|
|
128
|
+
(b"access-control-allow-methods", ", ".join(sorted(self.allow_methods)).encode()),
|
|
129
|
+
(b"access-control-max-age", str(self.max_age).encode()),
|
|
130
|
+
])
|
|
131
|
+
if requested_headers:
|
|
132
|
+
value = ", ".join(requested_headers) if "*" in self.allow_headers else ", ".join(sorted(self.allow_headers))
|
|
133
|
+
cors_headers.append((b"access-control-allow-headers", value.encode()))
|
|
134
|
+
await send({"type": "http.response.start", "status": 204,
|
|
135
|
+
"headers": cors_headers + [(b"content-length", b"0")]})
|
|
136
|
+
await send({"type": "http.response.body", "body": b""})
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
async def send_with_cors(message):
|
|
140
|
+
if message["type"] == "http.response.start":
|
|
141
|
+
message = {**message, "headers": list(message.get("headers", [])) + cors_headers}
|
|
142
|
+
await send(message)
|
|
143
|
+
|
|
144
|
+
await self.app(scope, receive, send_with_cors)
|
lenapi/model.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Model base class and field declarations for lenApi projects.
|
|
2
|
+
|
|
3
|
+
Import this module with ``from lenapi import model`` and declare fields as
|
|
4
|
+
``model.StringField(...)``, ``model.ForeignKeyField(...)``, and so on.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
AutoField,
|
|
9
|
+
Avg,
|
|
10
|
+
BigIntField,
|
|
11
|
+
BigIntegerField,
|
|
12
|
+
BooleanField,
|
|
13
|
+
CASCADE,
|
|
14
|
+
Count,
|
|
15
|
+
DateTimeField,
|
|
16
|
+
DecimalField,
|
|
17
|
+
EmailField,
|
|
18
|
+
Email_field,
|
|
19
|
+
FloatField,
|
|
20
|
+
ForeignKeyField,
|
|
21
|
+
ImageField,
|
|
22
|
+
IPField,
|
|
23
|
+
IP_field,
|
|
24
|
+
IntegerField,
|
|
25
|
+
JSONField,
|
|
26
|
+
ManyToManyField,
|
|
27
|
+
Max,
|
|
28
|
+
Min,
|
|
29
|
+
Model,
|
|
30
|
+
PhoneField,
|
|
31
|
+
Phone_field,
|
|
32
|
+
PROTECT,
|
|
33
|
+
SET_NULL,
|
|
34
|
+
StringField,
|
|
35
|
+
Sum,
|
|
36
|
+
TextField,
|
|
37
|
+
TExtfield,
|
|
38
|
+
UUIDField,
|
|
39
|
+
cascade,
|
|
40
|
+
null,
|
|
41
|
+
protected,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Short alias for JSON column declarations: ``model.Json()``.
|
|
45
|
+
Json = JSONField
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"Model", "AutoField", "IntegerField", "BigIntField", "BigIntegerField", "DecimalField", "StringField", "EmailField", "IPField", "PhoneField", "TextField",
|
|
49
|
+
"Email_field", "IP_field", "Phone_field", "TExtfield",
|
|
50
|
+
"BooleanField", "FloatField", "DateTimeField", "JSONField", "Json", "UUIDField", "ImageField",
|
|
51
|
+
"Count", "Sum", "Min", "Max", "Avg",
|
|
52
|
+
"ForeignKeyField", "ManyToManyField", "CASCADE", "SET_NULL", "PROTECT",
|
|
53
|
+
"cascade", "null", "protected",
|
|
54
|
+
]
|