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/security_store.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Shared database or Redis storage for stateful security features."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import delete, insert, select, update
|
|
8
|
+
from sqlalchemy.exc import IntegrityError
|
|
9
|
+
|
|
10
|
+
from .models import SECURITY_STORE_TABLE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SecurityStoreUnavailable(RuntimeError):
|
|
14
|
+
"""The selected shared store could not complete a security operation."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DatabaseSecurityStore:
|
|
18
|
+
"""Persist security state in the application's migrated SQL database."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, engine) -> None:
|
|
21
|
+
self.engine = engine
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def _key(key: str) -> str:
|
|
25
|
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
26
|
+
|
|
27
|
+
def _upsert(self, key: str, value: str | None, expires_at: float | None) -> None:
|
|
28
|
+
digest = self._key(key)
|
|
29
|
+
values = {"value": value, "expires_at": expires_at}
|
|
30
|
+
dialect = self.engine.dialect.name
|
|
31
|
+
if dialect == "sqlite":
|
|
32
|
+
from sqlalchemy.dialects.sqlite import insert as dialect_insert
|
|
33
|
+
statement = dialect_insert(SECURITY_STORE_TABLE).values(key=digest, **values)
|
|
34
|
+
statement = statement.on_conflict_do_update(index_elements=["key"], set_=values)
|
|
35
|
+
elif dialect == "postgresql":
|
|
36
|
+
from sqlalchemy.dialects.postgresql import insert as dialect_insert
|
|
37
|
+
statement = dialect_insert(SECURITY_STORE_TABLE).values(key=digest, **values)
|
|
38
|
+
statement = statement.on_conflict_do_update(index_elements=["key"], set_=values)
|
|
39
|
+
elif dialect == "mysql":
|
|
40
|
+
from sqlalchemy.dialects.mysql import insert as dialect_insert
|
|
41
|
+
statement = dialect_insert(SECURITY_STORE_TABLE).values(key=digest, **values)
|
|
42
|
+
statement = statement.on_duplicate_key_update(**values)
|
|
43
|
+
else:
|
|
44
|
+
with self.engine.begin() as connection:
|
|
45
|
+
result = connection.execute(
|
|
46
|
+
update(SECURITY_STORE_TABLE).where(SECURITY_STORE_TABLE.c.key == digest).values(**values)
|
|
47
|
+
)
|
|
48
|
+
if result.rowcount == 0:
|
|
49
|
+
try:
|
|
50
|
+
connection.execute(insert(SECURITY_STORE_TABLE).values(key=digest, **values))
|
|
51
|
+
except IntegrityError:
|
|
52
|
+
connection.execute(
|
|
53
|
+
update(SECURITY_STORE_TABLE).where(SECURITY_STORE_TABLE.c.key == digest).values(**values)
|
|
54
|
+
)
|
|
55
|
+
return
|
|
56
|
+
with self.engine.begin() as connection:
|
|
57
|
+
connection.execute(statement)
|
|
58
|
+
|
|
59
|
+
def _get(self, key: str) -> str | None:
|
|
60
|
+
digest = self._key(key)
|
|
61
|
+
now = time.time()
|
|
62
|
+
with self.engine.begin() as connection:
|
|
63
|
+
row = connection.execute(
|
|
64
|
+
select(SECURITY_STORE_TABLE.c.value, SECURITY_STORE_TABLE.c.expires_at)
|
|
65
|
+
.where(SECURITY_STORE_TABLE.c.key == digest)
|
|
66
|
+
).first()
|
|
67
|
+
if row is None:
|
|
68
|
+
return None
|
|
69
|
+
if row.expires_at is not None and row.expires_at <= now:
|
|
70
|
+
connection.execute(delete(SECURITY_STORE_TABLE).where(SECURITY_STORE_TABLE.c.key == digest))
|
|
71
|
+
return None
|
|
72
|
+
return row.value
|
|
73
|
+
|
|
74
|
+
def _set(self, key: str, value: str, *, ttl: int | None = None) -> None:
|
|
75
|
+
if ttl is not None and ttl <= 0:
|
|
76
|
+
raise ValueError("ttl must be a positive number of seconds")
|
|
77
|
+
self._upsert(key, value, time.time() + ttl if ttl is not None else None)
|
|
78
|
+
|
|
79
|
+
def _delete(self, key: str) -> None:
|
|
80
|
+
with self.engine.begin() as connection:
|
|
81
|
+
connection.execute(delete(SECURITY_STORE_TABLE).where(
|
|
82
|
+
SECURITY_STORE_TABLE.c.key == self._key(key)
|
|
83
|
+
))
|
|
84
|
+
|
|
85
|
+
def _claim(self, key: str, ttl: int) -> int:
|
|
86
|
+
if ttl <= 0:
|
|
87
|
+
raise ValueError("ttl must be a positive number of seconds")
|
|
88
|
+
digest = self._key(key)
|
|
89
|
+
now = time.time()
|
|
90
|
+
try:
|
|
91
|
+
with self.engine.begin() as connection:
|
|
92
|
+
connection.execute(delete(SECURITY_STORE_TABLE).where(
|
|
93
|
+
SECURITY_STORE_TABLE.c.key == digest,
|
|
94
|
+
SECURITY_STORE_TABLE.c.expires_at <= now,
|
|
95
|
+
))
|
|
96
|
+
connection.execute(insert(SECURITY_STORE_TABLE).values(
|
|
97
|
+
key=digest, value="1", expires_at=now + ttl
|
|
98
|
+
))
|
|
99
|
+
return 0
|
|
100
|
+
except IntegrityError:
|
|
101
|
+
with self.engine.connect() as connection:
|
|
102
|
+
expires_at = connection.execute(
|
|
103
|
+
select(SECURITY_STORE_TABLE.c.expires_at).where(SECURITY_STORE_TABLE.c.key == digest)
|
|
104
|
+
).scalar_one_or_none()
|
|
105
|
+
return max(1, int(expires_at - time.time() + 0.999)) if expires_at is not None else 0
|
|
106
|
+
|
|
107
|
+
async def get(self, key: str) -> str | None:
|
|
108
|
+
try:
|
|
109
|
+
return await asyncio.to_thread(self._get, key)
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
raise SecurityStoreUnavailable("Database security storage is unavailable") from exc
|
|
112
|
+
|
|
113
|
+
async def set(self, key: str, value: str, *, ttl: int | None = None) -> None:
|
|
114
|
+
try:
|
|
115
|
+
await asyncio.to_thread(self._set, key, value, ttl=ttl)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
raise SecurityStoreUnavailable("Database security storage is unavailable") from exc
|
|
118
|
+
|
|
119
|
+
async def delete(self, key: str) -> None:
|
|
120
|
+
try:
|
|
121
|
+
await asyncio.to_thread(self._delete, key)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
raise SecurityStoreUnavailable("Database security storage is unavailable") from exc
|
|
124
|
+
|
|
125
|
+
async def claim(self, key: str, ttl: int) -> int:
|
|
126
|
+
try:
|
|
127
|
+
return await asyncio.to_thread(self._claim, key, ttl)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
raise SecurityStoreUnavailable("Database security storage is unavailable") from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class RedisSecurityStore:
|
|
133
|
+
"""Store security state in Redis with atomic expiring operations."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, url: str) -> None:
|
|
136
|
+
try:
|
|
137
|
+
from redis.asyncio import Redis
|
|
138
|
+
except ImportError as exc:
|
|
139
|
+
raise ValueError("Redis security storage requires `pip install lenapi[redis]`") from exc
|
|
140
|
+
self.client = Redis.from_url(url, decode_responses=True)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _key(key: str) -> str:
|
|
144
|
+
return "lenapi:security:" + hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
145
|
+
|
|
146
|
+
async def get(self, key: str) -> str | None:
|
|
147
|
+
try:
|
|
148
|
+
return await self.client.get(self._key(key))
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
raise SecurityStoreUnavailable("Redis security storage is unavailable") from exc
|
|
151
|
+
|
|
152
|
+
async def set(self, key: str, value: str, *, ttl: int | None = None) -> None:
|
|
153
|
+
if ttl is not None and ttl <= 0:
|
|
154
|
+
raise ValueError("ttl must be a positive number of seconds")
|
|
155
|
+
try:
|
|
156
|
+
await self.client.set(self._key(key), value, ex=ttl)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
raise SecurityStoreUnavailable("Redis security storage is unavailable") from exc
|
|
159
|
+
|
|
160
|
+
async def delete(self, key: str) -> None:
|
|
161
|
+
try:
|
|
162
|
+
await self.client.delete(self._key(key))
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
raise SecurityStoreUnavailable("Redis security storage is unavailable") from exc
|
|
165
|
+
|
|
166
|
+
async def claim(self, key: str, ttl: int) -> int:
|
|
167
|
+
if ttl <= 0:
|
|
168
|
+
raise ValueError("ttl must be a positive number of seconds")
|
|
169
|
+
redis_key = self._key(key)
|
|
170
|
+
try:
|
|
171
|
+
if await self.client.set(redis_key, "1", ex=ttl, nx=True):
|
|
172
|
+
return 0
|
|
173
|
+
return max(1, await self.client.ttl(redis_key))
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
raise SecurityStoreUnavailable("Redis security storage is unavailable") from exc
|
|
176
|
+
|
|
177
|
+
async def close(self) -> None:
|
|
178
|
+
await self.client.aclose()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def create_security_store(backend: str, *, engine=None, redis_url: str | None = None):
|
|
182
|
+
backend = backend.lower()
|
|
183
|
+
if backend == "database":
|
|
184
|
+
if engine is None:
|
|
185
|
+
raise ValueError("Database security storage requires an application Database")
|
|
186
|
+
return DatabaseSecurityStore(engine)
|
|
187
|
+
if backend == "redis":
|
|
188
|
+
if not redis_url:
|
|
189
|
+
raise ValueError("Redis security storage requires REDIS_URL")
|
|
190
|
+
return RedisSecurityStore(redis_url)
|
|
191
|
+
if backend == "memory":
|
|
192
|
+
return None
|
|
193
|
+
raise ValueError("SECURITY_STORAGE must be 'database', 'redis', or 'memory'")
|
lenapi/service.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Optional services for WebSockets, caching, background jobs, and AI APIs."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WebSocketDisconnect(Exception):
|
|
11
|
+
"""Raised when a WebSocket peer disconnects during a receive operation."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, code: int = 1000) -> None:
|
|
14
|
+
super().__init__(f"WebSocket disconnected with code {code}")
|
|
15
|
+
self.code = code
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WebSocket:
|
|
19
|
+
"""Convenient send and receive methods for an ASGI WebSocket connection."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, scope: dict[str, Any], receive, send, *, path_params=None) -> None:
|
|
22
|
+
self.scope = scope
|
|
23
|
+
self._receive = receive
|
|
24
|
+
self._send = send
|
|
25
|
+
self.path_params = path_params or {}
|
|
26
|
+
self.accepted = False
|
|
27
|
+
self.closed = False
|
|
28
|
+
self.subprotocol: str | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def headers(self) -> dict[str, str]:
|
|
32
|
+
return {
|
|
33
|
+
name.decode("latin-1").lower(): value.decode("latin-1")
|
|
34
|
+
for name, value in self.scope.get("headers", [])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def query_string(self) -> str:
|
|
39
|
+
return self.scope.get("query_string", b"").decode("latin-1")
|
|
40
|
+
|
|
41
|
+
async def accept(self, subprotocol: str | None = None) -> None:
|
|
42
|
+
if self.closed:
|
|
43
|
+
raise RuntimeError("Cannot accept a closed WebSocket")
|
|
44
|
+
if self.accepted:
|
|
45
|
+
return
|
|
46
|
+
self.subprotocol = subprotocol
|
|
47
|
+
message = {"type": "websocket.accept"}
|
|
48
|
+
if subprotocol is not None:
|
|
49
|
+
message["subprotocol"] = subprotocol
|
|
50
|
+
await self._send(message)
|
|
51
|
+
self.accepted = True
|
|
52
|
+
|
|
53
|
+
async def receive(self) -> dict[str, Any]:
|
|
54
|
+
if self.closed:
|
|
55
|
+
raise WebSocketDisconnect()
|
|
56
|
+
message = await self._receive()
|
|
57
|
+
if message["type"] == "websocket.disconnect":
|
|
58
|
+
self.closed = True
|
|
59
|
+
raise WebSocketDisconnect(message.get("code", 1000))
|
|
60
|
+
if message["type"] != "websocket.receive":
|
|
61
|
+
raise RuntimeError(f"Unexpected ASGI WebSocket message: {message['type']}")
|
|
62
|
+
return message
|
|
63
|
+
|
|
64
|
+
async def receive_text(self) -> str:
|
|
65
|
+
message = await self.receive()
|
|
66
|
+
text = message.get("text")
|
|
67
|
+
if text is not None:
|
|
68
|
+
return text
|
|
69
|
+
data = message.get("bytes")
|
|
70
|
+
if data is not None:
|
|
71
|
+
return data.decode("utf-8")
|
|
72
|
+
raise ValueError("WebSocket message did not contain text or bytes")
|
|
73
|
+
|
|
74
|
+
async def receive_bytes(self) -> bytes:
|
|
75
|
+
message = await self.receive()
|
|
76
|
+
data = message.get("bytes")
|
|
77
|
+
if data is not None:
|
|
78
|
+
return data
|
|
79
|
+
text = message.get("text")
|
|
80
|
+
if text is not None:
|
|
81
|
+
return text.encode("utf-8")
|
|
82
|
+
raise ValueError("WebSocket message did not contain text or bytes")
|
|
83
|
+
|
|
84
|
+
async def receive_json(self) -> Any:
|
|
85
|
+
return json.loads(await self.receive_text())
|
|
86
|
+
|
|
87
|
+
async def send_text(self, data: str) -> None:
|
|
88
|
+
self._ensure_accepted()
|
|
89
|
+
await self._send({"type": "websocket.send", "text": data})
|
|
90
|
+
|
|
91
|
+
async def send_bytes(self, data: bytes) -> None:
|
|
92
|
+
self._ensure_accepted()
|
|
93
|
+
await self._send({"type": "websocket.send", "bytes": data})
|
|
94
|
+
|
|
95
|
+
async def send_json(self, data: Any) -> None:
|
|
96
|
+
await self.send_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
|
|
97
|
+
|
|
98
|
+
async def close(self, code: int = 1000, reason: str | None = None) -> None:
|
|
99
|
+
if self.closed:
|
|
100
|
+
return
|
|
101
|
+
message: dict[str, Any] = {"type": "websocket.close", "code": code}
|
|
102
|
+
if reason is not None:
|
|
103
|
+
message["reason"] = reason
|
|
104
|
+
await self._send(message)
|
|
105
|
+
self.closed = True
|
|
106
|
+
|
|
107
|
+
def _ensure_accepted(self) -> None:
|
|
108
|
+
if not self.accepted or self.closed:
|
|
109
|
+
raise RuntimeError("Accept the WebSocket before sending data")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Faker:
|
|
113
|
+
"""Optional wrapper for generating realistic fake data.
|
|
114
|
+
|
|
115
|
+
Install the integration with ``pip install 'lenapi[faker]'``. Faker
|
|
116
|
+
provider attributes such as ``name``, ``email``, and ``address`` are
|
|
117
|
+
delegated to the underlying Faker instance.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, *args, seed: int | float | str | bytes | None = None, **kwargs) -> None:
|
|
121
|
+
try:
|
|
122
|
+
from faker import Faker as FakerProvider
|
|
123
|
+
except ImportError as exc:
|
|
124
|
+
raise ImportError(
|
|
125
|
+
"Faker service requires the optional dependency; from the lenApi project root run "
|
|
126
|
+
"`python -m pip install -e '.[faker]'`."
|
|
127
|
+
) from exc
|
|
128
|
+
self._provider = FakerProvider(*args, **kwargs)
|
|
129
|
+
if seed is not None:
|
|
130
|
+
self._provider.seed_instance(seed)
|
|
131
|
+
|
|
132
|
+
def __getattr__(self, name: str) -> Any:
|
|
133
|
+
return getattr(self._provider, name)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _AIHTTPService:
|
|
137
|
+
"""Shared optional HTTPX transport for async model-provider services."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, *, timeout: float, client=None) -> None:
|
|
140
|
+
if timeout <= 0:
|
|
141
|
+
raise ValueError("timeout must be positive")
|
|
142
|
+
self._owns_client = client is None
|
|
143
|
+
if client is not None:
|
|
144
|
+
self._client = client
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
import httpx
|
|
148
|
+
except ImportError as exc:
|
|
149
|
+
raise ImportError("AI services require `pip install 'lenapi[ai]'`") from exc
|
|
150
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
151
|
+
|
|
152
|
+
async def close(self) -> None:
|
|
153
|
+
if self._owns_client:
|
|
154
|
+
await self._client.aclose()
|
|
155
|
+
|
|
156
|
+
async def __aenter__(self):
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
async def __aexit__(self, *_exc):
|
|
160
|
+
await self.close()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class OpenAI(_AIHTTPService):
|
|
164
|
+
"""Small async client for OpenAI chat-completion and embedding APIs.
|
|
165
|
+
|
|
166
|
+
Install HTTPX with ``pip install 'lenapi[ai]'``. The API key defaults to
|
|
167
|
+
the ``OPENAI_API_KEY`` environment variable.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, api_key: str | None = None, *, model: str = "gpt-4o-mini",
|
|
171
|
+
base_url: str = "https://api.openai.com/v1", timeout: float = 60, client=None) -> None:
|
|
172
|
+
import os
|
|
173
|
+
|
|
174
|
+
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
|
|
175
|
+
if not self.api_key:
|
|
176
|
+
raise ValueError("Pass api_key or set the OPENAI_API_KEY environment variable")
|
|
177
|
+
self.model = model
|
|
178
|
+
self.base_url = base_url.rstrip("/")
|
|
179
|
+
self._headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
180
|
+
super().__init__(timeout=timeout, client=client)
|
|
181
|
+
|
|
182
|
+
async def chat(self, messages: list[dict[str, str]], *, model: str | None = None,
|
|
183
|
+
**options) -> dict:
|
|
184
|
+
"""Call OpenAI's chat-completions endpoint and return its JSON response."""
|
|
185
|
+
response = await self._client.post(
|
|
186
|
+
f"{self.base_url}/chat/completions",
|
|
187
|
+
headers=self._headers,
|
|
188
|
+
json={"model": model or self.model, "messages": messages, **options},
|
|
189
|
+
)
|
|
190
|
+
response.raise_for_status()
|
|
191
|
+
return response.json()
|
|
192
|
+
|
|
193
|
+
async def responses(self, input, *, model: str | None = None, **options) -> dict:
|
|
194
|
+
"""Call OpenAI's Responses API and return its JSON response."""
|
|
195
|
+
response = await self._client.post(
|
|
196
|
+
f"{self.base_url}/responses",
|
|
197
|
+
headers=self._headers,
|
|
198
|
+
json={"model": model or self.model, "input": input, **options},
|
|
199
|
+
)
|
|
200
|
+
response.raise_for_status()
|
|
201
|
+
return response.json()
|
|
202
|
+
|
|
203
|
+
async def embeddings(self, input: str | list[str], *, model: str = "text-embedding-3-small",
|
|
204
|
+
**options) -> dict:
|
|
205
|
+
"""Create embeddings and return OpenAI's JSON response."""
|
|
206
|
+
response = await self._client.post(
|
|
207
|
+
f"{self.base_url}/embeddings",
|
|
208
|
+
headers=self._headers,
|
|
209
|
+
json={"model": model, "input": input, **options},
|
|
210
|
+
)
|
|
211
|
+
response.raise_for_status()
|
|
212
|
+
return response.json()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Ollama(_AIHTTPService):
|
|
216
|
+
"""Small async client for a local or remote Ollama HTTP server."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, model: str = "llama3.2", *, base_url: str = "http://127.0.0.1:11434",
|
|
219
|
+
timeout: float = 120, client=None) -> None:
|
|
220
|
+
self.model = model
|
|
221
|
+
self.base_url = base_url.rstrip("/")
|
|
222
|
+
super().__init__(timeout=timeout, client=client)
|
|
223
|
+
|
|
224
|
+
async def chat(self, messages: list[dict[str, str]], *, model: str | None = None,
|
|
225
|
+
options: dict | None = None, **parameters) -> dict:
|
|
226
|
+
"""Call Ollama's ``/api/chat`` endpoint without streaming."""
|
|
227
|
+
body = {"model": model or self.model, "messages": messages, "stream": False}
|
|
228
|
+
if options is not None:
|
|
229
|
+
body["options"] = options
|
|
230
|
+
body.update(parameters)
|
|
231
|
+
response = await self._client.post(f"{self.base_url}/api/chat", json=body)
|
|
232
|
+
response.raise_for_status()
|
|
233
|
+
return response.json()
|
|
234
|
+
|
|
235
|
+
async def generate(self, prompt: str, *, model: str | None = None,
|
|
236
|
+
options: dict | None = None, **parameters) -> dict:
|
|
237
|
+
"""Call Ollama's ``/api/generate`` endpoint without streaming."""
|
|
238
|
+
body = {"model": model or self.model, "prompt": prompt, "stream": False}
|
|
239
|
+
if options is not None:
|
|
240
|
+
body["options"] = options
|
|
241
|
+
body.update(parameters)
|
|
242
|
+
response = await self._client.post(f"{self.base_url}/api/generate", json=body)
|
|
243
|
+
response.raise_for_status()
|
|
244
|
+
return response.json()
|
|
245
|
+
|
|
246
|
+
async def embeddings(self, input: str | list[str], *, model: str | None = None,
|
|
247
|
+
**options) -> dict:
|
|
248
|
+
"""Create embeddings through Ollama's ``/api/embed`` endpoint."""
|
|
249
|
+
response = await self._client.post(
|
|
250
|
+
f"{self.base_url}/api/embed",
|
|
251
|
+
json={"model": model or self.model, "input": input, **options},
|
|
252
|
+
)
|
|
253
|
+
response.raise_for_status()
|
|
254
|
+
return response.json()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Cache:
|
|
258
|
+
"""JSON-value cache with memory, database, and optional Redis backends."""
|
|
259
|
+
|
|
260
|
+
def __init__(self, backend: str = "memory", *, engine=None, redis_url: str | None = None,
|
|
261
|
+
prefix: str = "cache:") -> None:
|
|
262
|
+
self.backend = backend.lower()
|
|
263
|
+
self.prefix = prefix
|
|
264
|
+
self._values: dict[str, tuple[float | None, str]] = {}
|
|
265
|
+
self._lock = threading.RLock()
|
|
266
|
+
self._store = None
|
|
267
|
+
if self.backend == "database":
|
|
268
|
+
if engine is None:
|
|
269
|
+
raise ValueError("Database cache requires a configured SQLAlchemy engine")
|
|
270
|
+
from .security_store import DatabaseSecurityStore
|
|
271
|
+
self._store = DatabaseSecurityStore(engine)
|
|
272
|
+
elif self.backend == "redis":
|
|
273
|
+
if not redis_url:
|
|
274
|
+
raise ValueError("Redis cache requires a redis_url")
|
|
275
|
+
from .security_store import RedisSecurityStore
|
|
276
|
+
self._store = RedisSecurityStore(redis_url)
|
|
277
|
+
elif self.backend != "memory":
|
|
278
|
+
raise ValueError("Cache backend must be 'memory', 'database', or 'redis'")
|
|
279
|
+
|
|
280
|
+
async def get(self, key: str, default=None):
|
|
281
|
+
"""Return a decoded cached value, or ``default`` if missing or expired."""
|
|
282
|
+
full_key = self.prefix + str(key)
|
|
283
|
+
if self._store is not None:
|
|
284
|
+
raw = await self._store.get(full_key)
|
|
285
|
+
if raw is None:
|
|
286
|
+
return default
|
|
287
|
+
try:
|
|
288
|
+
return json.loads(raw)
|
|
289
|
+
except (TypeError, json.JSONDecodeError):
|
|
290
|
+
return default
|
|
291
|
+
now = time.monotonic()
|
|
292
|
+
with self._lock:
|
|
293
|
+
entry = self._values.get(full_key)
|
|
294
|
+
if entry is None:
|
|
295
|
+
return default
|
|
296
|
+
expires_at, raw = entry
|
|
297
|
+
if expires_at is not None and expires_at <= now:
|
|
298
|
+
self._values.pop(full_key, None)
|
|
299
|
+
return default
|
|
300
|
+
return json.loads(raw)
|
|
301
|
+
|
|
302
|
+
async def set(self, key: str, value, *, ttl: float | None = None) -> None:
|
|
303
|
+
"""Store a JSON-compatible value, optionally expiring after ``ttl`` seconds."""
|
|
304
|
+
if ttl is not None and (isinstance(ttl, bool) or not isinstance(ttl, (int, float))
|
|
305
|
+
or not math.isfinite(ttl) or ttl <= 0):
|
|
306
|
+
raise ValueError("ttl must be a positive number of seconds")
|
|
307
|
+
raw = json.dumps(value, separators=(",", ":"))
|
|
308
|
+
full_key = self.prefix + str(key)
|
|
309
|
+
if self._store is not None:
|
|
310
|
+
store_ttl = None if ttl is None else max(1, math.ceil(ttl))
|
|
311
|
+
await self._store.set(full_key, raw, ttl=store_ttl)
|
|
312
|
+
return
|
|
313
|
+
expires_at = None if ttl is None else time.monotonic() + ttl
|
|
314
|
+
with self._lock:
|
|
315
|
+
self._values[full_key] = (expires_at, raw)
|
|
316
|
+
|
|
317
|
+
async def delete(self, key: str) -> None:
|
|
318
|
+
"""Delete a cached key if it exists."""
|
|
319
|
+
full_key = self.prefix + str(key)
|
|
320
|
+
if self._store is not None:
|
|
321
|
+
await self._store.delete(full_key)
|
|
322
|
+
return
|
|
323
|
+
with self._lock:
|
|
324
|
+
self._values.pop(full_key, None)
|
|
325
|
+
|
|
326
|
+
async def clear(self) -> None:
|
|
327
|
+
"""Clear the in-memory cache; shared stores require key-by-key deletion."""
|
|
328
|
+
if self._store is not None:
|
|
329
|
+
raise NotImplementedError("clear() is available only for the in-memory cache")
|
|
330
|
+
with self._lock:
|
|
331
|
+
self._values.clear()
|
|
332
|
+
|
|
333
|
+
async def close(self) -> None:
|
|
334
|
+
"""Close the optional Redis connection pool."""
|
|
335
|
+
if self.backend == "redis" and self._store is not None:
|
|
336
|
+
await self._store.close()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# Keep service entry points together for projects using ``lenapi.service``.
|
|
340
|
+
from .background import BackgroundQueue, BackgroundTasks # noqa: E402
|
|
341
|
+
from .payments import ( # noqa: E402
|
|
342
|
+
Flutterwave,
|
|
343
|
+
Nomba,
|
|
344
|
+
PaymentPlugin,
|
|
345
|
+
Payments,
|
|
346
|
+
Paystack,
|
|
347
|
+
Stripe,
|
|
348
|
+
payment_provider,
|
|
349
|
+
register_payment_provider,
|
|
350
|
+
)
|