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/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
+ ]