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.
@@ -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
+ )