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,96 @@
1
+ """Reusable role, permission, and object ownership dependencies."""
2
+
3
+ import inspect
4
+ from collections.abc import Mapping
5
+ from typing import Any, Callable
6
+
7
+ from .dependencies import Depends
8
+ from .request import Request
9
+ from .response import HTTPException
10
+
11
+
12
+ def _claim(identity: Any, *names: str) -> Any:
13
+ if isinstance(identity, Mapping):
14
+ for name in names:
15
+ if name in identity:
16
+ return identity[name]
17
+ elif isinstance(identity, (str, int)) and {"sub", "user_id", "id"}.intersection(names):
18
+ return identity
19
+ return None
20
+
21
+
22
+ def _as_set(value: Any) -> set[str]:
23
+ if value is None:
24
+ return set()
25
+ if isinstance(value, str):
26
+ return set(value.split())
27
+ if isinstance(value, (list, tuple, set, frozenset)):
28
+ return {str(item) for item in value}
29
+ return {str(value)}
30
+
31
+
32
+ def require_role(*roles: str, auth: Callable) -> Callable:
33
+ """Build a dependency that requires at least one named role."""
34
+ required = {role for role in roles if role}
35
+ if not required:
36
+ raise ValueError("require_role needs at least one role")
37
+
38
+ async def check_role(identity=Depends(auth)):
39
+ actual = _as_set(_claim(identity, "roles", "role"))
40
+ if not required.intersection(actual):
41
+ raise HTTPException(403, "Insufficient role")
42
+ return identity
43
+
44
+ return check_role
45
+
46
+
47
+ def require_permission(*permissions: str, auth: Callable, match_all: bool = False) -> Callable:
48
+ """Build a dependency that checks permissions or OAuth-style scopes."""
49
+ required = {permission for permission in permissions if permission}
50
+ if not required:
51
+ raise ValueError("require_permission needs at least one permission")
52
+
53
+ async def check_permission(identity=Depends(auth)):
54
+ actual = _as_set(_claim(identity, "permissions", "scopes", "scope"))
55
+ allowed = required.issubset(actual) if match_all else bool(required.intersection(actual))
56
+ if not allowed:
57
+ raise HTTPException(403, "Insufficient permission")
58
+ return identity
59
+
60
+ return check_permission
61
+
62
+
63
+ def require_owner(
64
+ load_object: Callable[[str], Any],
65
+ *,
66
+ auth: Callable,
67
+ path_param: str = "id",
68
+ owner_field: str = "user_id",
69
+ ) -> Callable:
70
+ """Build a dependency that loads a route object and permits only its owner.
71
+
72
+ ``load_object`` receives the string path parameter and returns the model row
73
+ (or ``None``). The authenticated identity must contain ``sub``, ``user_id``,
74
+ or ``id``. The loaded object is returned to the endpoint.
75
+ """
76
+ async def check_owner(request: Request, identity=Depends(auth)):
77
+ object_id = request.path_params.get(path_param)
78
+ if object_id is None:
79
+ raise HTTPException(400, f"Missing object path parameter '{path_param}'")
80
+ resource = load_object(str(object_id))
81
+ if inspect.isawaitable(resource):
82
+ resource = await resource
83
+ if resource is None:
84
+ raise HTTPException(404, "Not found")
85
+
86
+ if isinstance(resource, Mapping):
87
+ owner_id = resource.get(owner_field)
88
+ else:
89
+ owner_id = getattr(resource, owner_field, None)
90
+ subject = _claim(identity, "sub", "user_id", "id")
91
+ if owner_id is None or subject is None or str(owner_id) != str(subject):
92
+ # Hide whether another user's object exists.
93
+ raise HTTPException(404, "Not found")
94
+ return resource
95
+
96
+ return check_owner
lenapi/background.py ADDED
@@ -0,0 +1,395 @@
1
+ """Small in-process background task support for route handlers."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from collections.abc import Callable
10
+
11
+ from sqlalchemy import and_, or_, select, update
12
+
13
+ from .models import BACKGROUND_JOBS_TABLE
14
+
15
+ from .logging import get_logger
16
+
17
+ logger = get_logger("background")
18
+
19
+ _MEMORY_QUEUE_LOCK = threading.RLock()
20
+ _MEMORY_QUEUE_JOBS: dict[str, dict[str, dict]] = {}
21
+
22
+
23
+ class BackgroundTasks:
24
+ """Collect functions to run after the response body has been sent."""
25
+
26
+ def __init__(self) -> None:
27
+ self._tasks: list[tuple[Callable, tuple, dict]] = []
28
+
29
+ def add_task(self, function: Callable, *args, **kwargs) -> None:
30
+ if not callable(function):
31
+ raise TypeError("background task must be callable")
32
+ self._tasks.append((function, args, kwargs))
33
+
34
+ async def run(self) -> None:
35
+ tasks, self._tasks = self._tasks, []
36
+ for function, args, kwargs in tasks:
37
+ try:
38
+ if inspect.iscoroutinefunction(function):
39
+ await function(*args, **kwargs)
40
+ else:
41
+ result = await asyncio.to_thread(function, *args, **kwargs)
42
+ if inspect.isawaitable(result):
43
+ await result
44
+ except Exception:
45
+ logger.exception("Background task %r failed", function)
46
+
47
+
48
+ class BackgroundQueue:
49
+ """A database-, Redis-, or in-memory-cache-backed queue with an async worker.
50
+
51
+ Import/register the same task functions in the API and worker process. Jobs
52
+ use JSON-compatible positional and keyword arguments, and delivery is at
53
+ least once, so handlers should be idempotent.
54
+ """
55
+
56
+ def __init__(self, database=None, *, backend: str = "database", redis_url: str | None = None,
57
+ queue_name: str = "default", max_attempts: int = 3, retry_delay: float = 5,
58
+ lease_seconds: float = 60) -> None:
59
+ backend = backend.lower()
60
+ if backend in {"cache", "system", "system_cache"}:
61
+ backend = "memory"
62
+ if backend not in {"database", "redis", "memory"}:
63
+ raise ValueError("BackgroundQueue backend must be 'database', 'redis', or 'memory'/'cache'")
64
+ if backend == "database" and (database is None or not hasattr(database, "engine")):
65
+ raise ValueError("Database BackgroundQueue requires a configured lenApi Database")
66
+ if backend == "redis" and not redis_url:
67
+ raise ValueError("Redis BackgroundQueue requires redis_url")
68
+ if isinstance(max_attempts, bool) or not isinstance(max_attempts, int) or max_attempts < 1:
69
+ raise ValueError("max_attempts must be a positive integer")
70
+ if retry_delay < 0 or lease_seconds <= 0:
71
+ raise ValueError("retry_delay must be non-negative and lease_seconds must be positive")
72
+ self.backend = backend
73
+ self.database = database
74
+ self.engine = database.engine if database is not None else None
75
+ self.max_attempts = max_attempts
76
+ self.retry_delay = float(retry_delay)
77
+ self.lease_seconds = float(lease_seconds)
78
+ self._tasks: dict[str, Callable] = {}
79
+ self.queue_name = queue_name
80
+ self._memory_jobs = _MEMORY_QUEUE_JOBS.setdefault(queue_name, {})
81
+ self._stream = f"lenapi:jobs:{queue_name}"
82
+ self._group = f"lenapi-workers:{queue_name}"
83
+ self._consumer = uuid.uuid4().hex
84
+ self._redis = None
85
+ if backend == "redis":
86
+ try:
87
+ from redis.asyncio import Redis
88
+ except ImportError as exc:
89
+ raise ImportError("Redis BackgroundQueue requires `pip install 'lenapi[redis]'`") from exc
90
+ self._redis = Redis.from_url(redis_url, decode_responses=True)
91
+
92
+ def task(self, name: str | None = None):
93
+ """Register a worker handler with ``@queue.task('send_email')``."""
94
+ def register(function):
95
+ task_name = name or f"{function.__module__}.{function.__name__}"
96
+ if not task_name or len(task_name) > 200:
97
+ raise ValueError("task names must contain 1 to 200 characters")
98
+ if task_name in self._tasks and self._tasks[task_name] is not function:
99
+ raise ValueError(f"A background task named {task_name!r} is already registered")
100
+ self._tasks[task_name] = function
101
+ return function
102
+ return register
103
+
104
+ async def enqueue(self, task_name: str, *args, **kwargs) -> str:
105
+ """Queue a named task and return its job ID."""
106
+ if not isinstance(task_name, str) or not task_name or len(task_name) > 200:
107
+ raise ValueError("task_name must contain 1 to 200 characters")
108
+ payload = {"args": args, "kwargs": kwargs}
109
+ encoded = json.dumps(payload, separators=(",", ":"))
110
+ job_id = uuid.uuid4().hex
111
+ now = time.time()
112
+
113
+ if self.backend == "memory":
114
+ with _MEMORY_QUEUE_LOCK:
115
+ self._memory_jobs[job_id] = {
116
+ "job_id": job_id,
117
+ "task_name": task_name,
118
+ "payload": json.loads(encoded),
119
+ "status": "queued",
120
+ "attempts": 0,
121
+ "available_at": now,
122
+ "locked_until": None,
123
+ "last_error": None,
124
+ "created_at": now,
125
+ "completed_at": None,
126
+ }
127
+ return job_id
128
+
129
+ if self.backend == "redis":
130
+ event_id = await self._redis.xadd(self._stream, {
131
+ "job_id": job_id,
132
+ "task_name": task_name,
133
+ "payload": encoded,
134
+ "attempts": "0",
135
+ "available_at": str(now),
136
+ })
137
+ await self._redis.hset(self._redis_state_key(job_id), mapping={
138
+ "status": "queued", "attempts": "0", "task_name": task_name,
139
+ "stream_id": event_id, "last_error": "",
140
+ })
141
+ return job_id
142
+
143
+ def insert_job():
144
+ with self.engine.begin() as connection:
145
+ connection.execute(BACKGROUND_JOBS_TABLE.insert().values(
146
+ job_id=job_id,
147
+ task_name=task_name,
148
+ payload=json.loads(encoded),
149
+ status="queued",
150
+ attempts=0,
151
+ available_at=now,
152
+ created_at=now,
153
+ ))
154
+
155
+ await asyncio.to_thread(insert_job)
156
+ return job_id
157
+
158
+ def _claim(self):
159
+ now = time.time()
160
+ ready = and_(BACKGROUND_JOBS_TABLE.c.status == "queued",
161
+ BACKGROUND_JOBS_TABLE.c.available_at <= now)
162
+ expired = and_(BACKGROUND_JOBS_TABLE.c.status == "running",
163
+ BACKGROUND_JOBS_TABLE.c.locked_until <= now)
164
+ statement = (
165
+ select(BACKGROUND_JOBS_TABLE)
166
+ .where(or_(ready, expired))
167
+ .order_by(BACKGROUND_JOBS_TABLE.c.available_at, BACKGROUND_JOBS_TABLE.c.created_at)
168
+ .limit(1)
169
+ .with_for_update(skip_locked=True)
170
+ )
171
+ with self.engine.begin() as connection:
172
+ row = connection.execute(statement).mappings().first()
173
+ if row is None:
174
+ return None
175
+ attempts = row["attempts"] + 1
176
+ connection.execute(
177
+ update(BACKGROUND_JOBS_TABLE)
178
+ .where(BACKGROUND_JOBS_TABLE.c.job_id == row["job_id"])
179
+ .values(status="running", attempts=attempts, locked_until=now + self.lease_seconds)
180
+ )
181
+ return {**row, "attempts": attempts}
182
+
183
+ def _claim_memory(self):
184
+ now = time.time()
185
+ with _MEMORY_QUEUE_LOCK:
186
+ eligible = [
187
+ job for job in self._memory_jobs.values()
188
+ if (job["status"] == "queued" and job["available_at"] <= now)
189
+ or (job["status"] == "running" and (job["locked_until"] or 0) <= now)
190
+ ]
191
+ if not eligible:
192
+ return None
193
+ job = min(eligible, key=lambda item: (item["available_at"], item["created_at"]))
194
+ job["status"] = "running"
195
+ job["attempts"] += 1
196
+ job["locked_until"] = now + self.lease_seconds
197
+ job["last_error"] = None
198
+ return dict(job)
199
+
200
+ def _finish_memory(self, job_id: str, attempts: int, *, error: str | None = None) -> None:
201
+ now = time.time()
202
+ with _MEMORY_QUEUE_LOCK:
203
+ job = self._memory_jobs.get(job_id)
204
+ if job is None:
205
+ return
206
+ job["locked_until"] = None
207
+ if error is None:
208
+ job.update(status="done", last_error=None, completed_at=now)
209
+ elif attempts >= self.max_attempts:
210
+ job.update(status="failed", last_error=error[:4000], completed_at=now)
211
+ else:
212
+ job.update(
213
+ status="queued",
214
+ available_at=now + self.retry_delay * attempts,
215
+ last_error=error[:4000],
216
+ )
217
+
218
+ def _redis_state_key(self, job_id: str) -> str:
219
+ return f"lenapi:job-status:{self.queue_name}:{job_id}"
220
+
221
+ async def _ensure_redis_group(self):
222
+ try:
223
+ await self._redis.xgroup_create(self._stream, self._group, id="0", mkstream=True)
224
+ except Exception as exc:
225
+ if "BUSYGROUP" not in str(exc):
226
+ raise
227
+
228
+ async def _claim_redis(self):
229
+ await self._ensure_redis_group()
230
+ messages = []
231
+ try:
232
+ claimed = await self._redis.xautoclaim(
233
+ self._stream, self._group, self._consumer,
234
+ min_idle_time=max(1, int(self.lease_seconds * 1000)),
235
+ start_id="0-0", count=1,
236
+ )
237
+ if len(claimed) > 1:
238
+ messages = claimed[1]
239
+ except (AttributeError, NotImplementedError):
240
+ # Older Redis clients/servers can still consume new jobs; pending
241
+ # job recovery requires Redis Streams XAUTOCLAIM support.
242
+ pass
243
+ if not messages:
244
+ response = await self._redis.xreadgroup(
245
+ self._group, self._consumer, {self._stream: ">"}, count=1
246
+ )
247
+ if response:
248
+ messages = response[0][1]
249
+ if not messages:
250
+ return None
251
+ stream_id, fields = messages[0]
252
+ if float(fields.get("available_at", "0")) > time.time():
253
+ await self._redis.xack(self._stream, self._group, stream_id)
254
+ await self._redis.xdel(self._stream, stream_id)
255
+ await self._redis.xadd(self._stream, fields)
256
+ return None
257
+ job = {
258
+ "stream_id": stream_id,
259
+ "job_id": fields["job_id"],
260
+ "task_name": fields["task_name"],
261
+ "payload": json.loads(fields["payload"]),
262
+ "attempts": int(fields.get("attempts", "0")) + 1,
263
+ }
264
+ await self._redis.hset(self._redis_state_key(job["job_id"]), mapping={
265
+ "status": "running", "attempts": str(job["attempts"]),
266
+ "last_error": "", "stream_id": stream_id,
267
+ })
268
+ return job
269
+
270
+ async def _finish_redis(self, job: dict, *, error: str | None = None):
271
+ stream_id = job["stream_id"]
272
+ job_id = job["job_id"]
273
+ state_key = self._redis_state_key(job_id)
274
+ if error is None:
275
+ await self._redis.xack(self._stream, self._group, stream_id)
276
+ await self._redis.xdel(self._stream, stream_id)
277
+ await self._redis.hset(state_key, mapping={
278
+ "status": "done", "attempts": str(job["attempts"]), "last_error": "",
279
+ })
280
+ return
281
+ if job["attempts"] >= self.max_attempts:
282
+ await self._redis.xack(self._stream, self._group, stream_id)
283
+ await self._redis.xdel(self._stream, stream_id)
284
+ await self._redis.hset(state_key, mapping={
285
+ "status": "failed", "attempts": str(job["attempts"]), "last_error": error[:4000],
286
+ })
287
+ return
288
+ await self._redis.xadd(self._stream, {
289
+ "job_id": job_id,
290
+ "task_name": job["task_name"],
291
+ "payload": json.dumps(job["payload"], separators=(",", ":")),
292
+ "attempts": str(job["attempts"]),
293
+ "available_at": str(time.time() + self.retry_delay * job["attempts"]),
294
+ })
295
+ await self._redis.xack(self._stream, self._group, stream_id)
296
+ await self._redis.xdel(self._stream, stream_id)
297
+ await self._redis.hset(state_key, mapping={
298
+ "status": "queued", "attempts": str(job["attempts"]), "last_error": error[:4000],
299
+ })
300
+
301
+ def _finish(self, job_id: str, attempts: int, *, error: str | None = None) -> None:
302
+ now = time.time()
303
+ if error is None:
304
+ values = {"status": "done", "locked_until": None, "last_error": None, "completed_at": now}
305
+ elif attempts >= self.max_attempts:
306
+ values = {"status": "failed", "locked_until": None, "last_error": error[:4000]}
307
+ else:
308
+ values = {
309
+ "status": "queued", "locked_until": None,
310
+ "available_at": now + self.retry_delay * attempts,
311
+ "last_error": error[:4000],
312
+ }
313
+ with self.engine.begin() as connection:
314
+ connection.execute(update(BACKGROUND_JOBS_TABLE)
315
+ .where(BACKGROUND_JOBS_TABLE.c.job_id == job_id)
316
+ .values(**values))
317
+
318
+ async def run_once(self) -> bool:
319
+ """Claim and execute one available job; return False when the queue is empty."""
320
+ if self.backend == "redis":
321
+ job = await self._claim_redis()
322
+ elif self.backend == "memory":
323
+ job = self._claim_memory()
324
+ else:
325
+ job = await asyncio.to_thread(self._claim)
326
+ if job is None:
327
+ return False
328
+ try:
329
+ function = self._tasks[job["task_name"]]
330
+ payload = job["payload"]
331
+ if inspect.iscoroutinefunction(function):
332
+ await function(*payload["args"], **payload["kwargs"])
333
+ else:
334
+ result = await asyncio.to_thread(function, *payload["args"], **payload["kwargs"])
335
+ if inspect.isawaitable(result):
336
+ await result
337
+ except Exception as exc:
338
+ logger.exception("Background job %s (%s) failed", job["job_id"], job["task_name"])
339
+ if self.backend == "redis":
340
+ await self._finish_redis(job, error=repr(exc))
341
+ elif self.backend == "memory":
342
+ self._finish_memory(job["job_id"], job["attempts"], error=repr(exc))
343
+ else:
344
+ await asyncio.to_thread(self._finish, job["job_id"], job["attempts"], error=repr(exc))
345
+ else:
346
+ if self.backend == "redis":
347
+ await self._finish_redis(job)
348
+ elif self.backend == "memory":
349
+ self._finish_memory(job["job_id"], job["attempts"])
350
+ else:
351
+ await asyncio.to_thread(self._finish, job["job_id"], job["attempts"])
352
+ return True
353
+
354
+ async def run_worker(self, *, poll_interval: float = 1, stop_event: asyncio.Event | None = None) -> None:
355
+ """Keep processing jobs until ``stop_event`` is set."""
356
+ if poll_interval <= 0:
357
+ raise ValueError("poll_interval must be positive")
358
+ while stop_event is None or not stop_event.is_set():
359
+ found_job = await self.run_once()
360
+ if not found_job:
361
+ try:
362
+ await asyncio.wait_for(
363
+ stop_event.wait() if stop_event is not None else asyncio.sleep(poll_interval),
364
+ timeout=poll_interval,
365
+ )
366
+ except asyncio.TimeoutError:
367
+ pass
368
+
369
+ async def status(self, job_id: str) -> dict | None:
370
+ """Return the current state for a job."""
371
+ if self.backend == "redis":
372
+ state = await self._redis.hgetall(self._redis_state_key(job_id))
373
+ if not state:
374
+ return None
375
+ if "attempts" in state:
376
+ state["attempts"] = int(state["attempts"])
377
+ return state
378
+
379
+ if self.backend == "memory":
380
+ with _MEMORY_QUEUE_LOCK:
381
+ job = self._memory_jobs.get(job_id)
382
+ return None if job is None else dict(job)
383
+
384
+ def read_job():
385
+ with self.engine.connect() as connection:
386
+ row = connection.execute(
387
+ select(BACKGROUND_JOBS_TABLE).where(BACKGROUND_JOBS_TABLE.c.job_id == job_id)
388
+ ).mappings().first()
389
+ return None if row is None else dict(row)
390
+ return await asyncio.to_thread(read_job)
391
+
392
+ async def close(self) -> None:
393
+ """Close the optional Redis client connection pool."""
394
+ if self._redis is not None:
395
+ await self._redis.aclose()