tempid 2.0.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.
tempid/backends.py ADDED
@@ -0,0 +1,449 @@
1
+ """
2
+ tempid.backends — Pluggable use-count backends for max_uses tokens.
3
+
4
+ Default backend is MemoryBackend (zero dependencies, single-process only).
5
+ For production multi-worker deployments, use RedisBackend, MongoBackend,
6
+ MySQLBackend, or PostgreSQLBackend.
7
+
8
+ Usage::
9
+
10
+ from tempid import TempID
11
+ from tempid.backends import RedisBackend
12
+
13
+ TempID.configure(store=RedisBackend("redis://localhost:6379"))
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ import time
20
+ from typing import Protocol, runtime_checkable
21
+
22
+
23
+ @runtime_checkable
24
+ class BaseBackend(Protocol):
25
+ """Protocol that all backends must satisfy."""
26
+
27
+ def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
28
+ """Atomically increment use count. Returns True if allowed, False if limit reached."""
29
+ ...
30
+
31
+ def use_count(self, token_id: str) -> int:
32
+ """Return current use count for a token_id."""
33
+ ...
34
+
35
+ def close(self) -> None:
36
+ """Close backend connections and release resources."""
37
+ ...
38
+
39
+
40
+ @runtime_checkable
41
+ class AsyncBaseBackend(Protocol):
42
+ """Protocol that all async backends must satisfy."""
43
+
44
+ async def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
45
+ """Atomically increment use count asynchronously. Returns True if allowed, False if limit reached."""
46
+ ...
47
+
48
+ async def use_count(self, token_id: str) -> int:
49
+ """Return current use count for a token_id asynchronously."""
50
+ ...
51
+
52
+ async def aclose(self) -> None:
53
+ """Close backend connections and release resources asynchronously."""
54
+ ...
55
+
56
+
57
+ class MemoryBackend:
58
+ """
59
+ In-process memory backend. Zero dependencies.
60
+
61
+ Suitable for: development, single-process apps (Flask dev server,
62
+ FastAPI with 1 worker, scripts).
63
+
64
+ WARNING: NOT suitable for multi-worker production deployments.
65
+ Under Gunicorn/Uvicorn with multiple workers, each process has its
66
+ own memory — each worker tracks uses independently. A token with
67
+ max_uses=1 could be used N times across N workers.
68
+ Use RedisBackend for distributed deployments.
69
+ """
70
+
71
+ def __init__(self) -> None:
72
+ self._lock = threading.Lock()
73
+ self._store: dict[str, int] = {}
74
+ self._expiry: dict[str, int] = {}
75
+
76
+ def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
77
+ with self._lock:
78
+ self._gc()
79
+ current = self._store.get(token_id, 0)
80
+ if current >= max_uses:
81
+ return False
82
+ self._store[token_id] = current + 1
83
+ if expires_at:
84
+ self._expiry[token_id] = expires_at
85
+ return True
86
+
87
+ def use_count(self, token_id: str) -> int:
88
+ with self._lock:
89
+ return self._store.get(token_id, 0)
90
+
91
+ def close(self) -> None:
92
+ """No-op for MemoryBackend."""
93
+ pass
94
+
95
+ def _gc(self) -> None:
96
+ """Lazy garbage collection — remove expired token entries."""
97
+ now = int(time.time())
98
+ expired = [tid for tid, exp in self._expiry.items() if exp < now]
99
+ for tid in expired:
100
+ self._store.pop(tid, None)
101
+ self._expiry.pop(tid, None)
102
+
103
+
104
+ class SQLiteBackend:
105
+ """
106
+ SQLite backend. Persists use counts across server restarts.
107
+ Safe for single-machine, multi-threaded deployments (WAL mode).
108
+
109
+ Args:
110
+ path: Path to the SQLite database file. Defaults to ``"tempid_uses.db"``.
111
+
112
+ WARNING: NOT suitable for distributed multi-machine deployments.
113
+ Use RedisBackend for that.
114
+ """
115
+
116
+ def __init__(self, path: str = "tempid_uses.db") -> None:
117
+ import sqlite3
118
+ from typing import Any
119
+
120
+ self._lock = threading.Lock()
121
+ self._conn: Any = sqlite3.connect(path, check_same_thread=False, timeout=10)
122
+ self._conn.execute("PRAGMA journal_mode=WAL") # concurrent reads/writes
123
+ self._conn.execute("""
124
+ CREATE TABLE IF NOT EXISTS tempid_uses (
125
+ token_id TEXT PRIMARY KEY,
126
+ count INTEGER NOT NULL DEFAULT 0,
127
+ expires_at INTEGER NOT NULL DEFAULT 0
128
+ )
129
+ """)
130
+ self._conn.commit()
131
+
132
+ def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
133
+ with self._lock:
134
+ self._cleanup()
135
+ cur = self._conn.execute(
136
+ "SELECT count FROM tempid_uses WHERE token_id = ?", (token_id,)
137
+ ).fetchone()
138
+ current = cur[0] if cur else 0
139
+ if current >= max_uses:
140
+ return False
141
+ self._conn.execute(
142
+ "INSERT INTO tempid_uses (token_id, count, expires_at) VALUES (?, 1, ?) "
143
+ "ON CONFLICT(token_id) DO UPDATE SET count = count + 1",
144
+ (token_id, expires_at),
145
+ )
146
+ self._conn.commit()
147
+ return True
148
+
149
+ def use_count(self, token_id: str) -> int:
150
+ with self._lock:
151
+ cur = self._conn.execute(
152
+ "SELECT count FROM tempid_uses WHERE token_id = ?", (token_id,)
153
+ ).fetchone()
154
+ return cur[0] if cur else 0
155
+
156
+ def _cleanup(self) -> None:
157
+ """Delete rows for expired tokens — prevents unbounded DB growth."""
158
+ self._conn.execute(
159
+ "DELETE FROM tempid_uses WHERE expires_at > 0 AND expires_at < ?",
160
+ (int(time.time()),),
161
+ )
162
+ self._conn.commit()
163
+
164
+ def close(self) -> None:
165
+ with self._lock:
166
+ if self._conn:
167
+ self._conn.close()
168
+ self._conn = None
169
+
170
+
171
+ # Atomic Lua script — executes as a single Redis command, no race conditions possible
172
+ _LUA_INCR = """
173
+ local current = redis.call('INCR', KEYS[1])
174
+ if current > tonumber(ARGV[1]) then
175
+ redis.call('DECR', KEYS[1])
176
+ return 0
177
+ end
178
+ local expires_at = tonumber(ARGV[2])
179
+ if current == 1 and expires_at and expires_at > 0 then
180
+ -- Convert unix timestamp (seconds) to milliseconds for PEXPIREAT
181
+ redis.call('PEXPIREAT', KEYS[1], expires_at * 1000)
182
+ end
183
+ return 1
184
+ """
185
+
186
+
187
+ class RedisBackend:
188
+ """
189
+ Redis backend. Safe for distributed, multi-worker production deployments.
190
+ Uses an atomic Lua script to guarantee no race conditions under concurrency.
191
+
192
+ Args:
193
+ url: Redis connection URL. Defaults to ``"redis://localhost:6379"``.
194
+
195
+ Requires: ``pip install tempid[redis]``
196
+ """
197
+
198
+ def __init__(self, url: str = "redis://localhost:6379") -> None:
199
+ try:
200
+ import redis as redis_lib
201
+ except ImportError:
202
+ raise ImportError(
203
+ "RedisBackend requires the redis package. "
204
+ "Install it with: pip install tempid[redis]"
205
+ )
206
+ self._redis = redis_lib.from_url(url)
207
+
208
+ def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
209
+ # Lua executes atomically in Redis — impossible to race
210
+ result = self._redis.eval(_LUA_INCR, 1, f"tempid:{token_id}", max_uses, expires_at)
211
+ return bool(result)
212
+
213
+ def use_count(self, token_id: str) -> int:
214
+ val = self._redis.get(f"tempid:{token_id}")
215
+ return int(val) if val else 0
216
+
217
+ def close(self) -> None:
218
+ self._redis.close()
219
+
220
+
221
+ class MongoBackend:
222
+ """
223
+ MongoDB backend. Safe for distributed, multi-worker production deployments.
224
+ Uses ``find_one_and_update`` for atomic document-level operations.
225
+
226
+ Args:
227
+ uri: MongoDB connection URI. Defaults to ``"mongodb://localhost:27017"``.
228
+ db: Database name. Defaults to ``"tempid"``.
229
+
230
+ Requires: ``pip install tempid[mongo]``
231
+ """
232
+
233
+ def __init__(self, uri: str = "mongodb://localhost:27017", db: str = "tempid") -> None:
234
+ try:
235
+ from pymongo import MongoClient
236
+ except ImportError:
237
+ raise ImportError(
238
+ "MongoBackend requires the pymongo package. "
239
+ "Install it with: pip install tempid[mongo]"
240
+ )
241
+ from typing import Any
242
+ self._col: Any = MongoClient(uri)[db]["uses"]
243
+ self._col.create_index("token_id", unique=True)
244
+
245
+ def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
246
+ from pymongo.errors import DuplicateKeyError
247
+ try:
248
+ # We assume max_uses >= 1 here. This is safely enforced by TempID.use()
249
+ # which skips the backend entirely if max_uses == 0.
250
+ self._col.insert_one({"token_id": token_id, "count": 1, "expires_at": expires_at})
251
+ return True
252
+ except DuplicateKeyError:
253
+ result = self._col.find_one_and_update(
254
+ {"token_id": token_id, "count": {"$lt": max_uses}},
255
+ {"$inc": {"count": 1}},
256
+ return_document=True,
257
+ )
258
+ return result is not None
259
+
260
+ def use_count(self, token_id: str) -> int:
261
+ doc = self._col.find_one({"token_id": token_id})
262
+ return doc["count"] if doc else 0
263
+
264
+ def close(self) -> None:
265
+ if hasattr(self, "_col") and self._col.database.client:
266
+ self._col.database.client.close()
267
+
268
+
269
+ class MySQLBackend:
270
+ """
271
+ MySQL/MariaDB backend. Safe for single and multi-machine deployments.
272
+ Uses a connection pool and ``SELECT FOR UPDATE`` within an explicit
273
+ transaction for atomic increments.
274
+
275
+ Args:
276
+ host, port, user, password, db: MySQL connection parameters.
277
+ max_connections: Pool size. Defaults to ``10``.
278
+
279
+ Requires: ``pip install tempid[mysql]``
280
+ """
281
+
282
+ def __init__(
283
+ self,
284
+ host: str = "localhost",
285
+ port: int = 3306,
286
+ user: str = "root",
287
+ password: str = "",
288
+ db: str = "tempid",
289
+ max_connections: int = 10,
290
+ ) -> None:
291
+ try:
292
+ import pymysql
293
+ from dbutils.pooled_db import PooledDB
294
+ except ImportError:
295
+ raise ImportError(
296
+ "MySQLBackend requires pymysql and dbutils. "
297
+ "Install with: pip install tempid[mysql]"
298
+ )
299
+ self._pool = PooledDB(
300
+ creator=pymysql,
301
+ maxconnections=max_connections,
302
+ host=host,
303
+ port=port,
304
+ user=user,
305
+ password=password,
306
+ database=db,
307
+ )
308
+ self._setup()
309
+
310
+ def _setup(self) -> None:
311
+ conn = self._pool.connection()
312
+ try:
313
+ with conn.cursor() as cur:
314
+ cur.execute("""
315
+ CREATE TABLE IF NOT EXISTS tempid_uses (
316
+ token_id VARCHAR(64) PRIMARY KEY,
317
+ count INT NOT NULL DEFAULT 0,
318
+ expires_at INT NOT NULL DEFAULT 0
319
+ )
320
+ """)
321
+ conn.commit()
322
+ finally:
323
+ conn.close()
324
+
325
+ def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
326
+ conn = self._pool.connection()
327
+ try:
328
+ conn.begin() # explicit transaction — required for FOR UPDATE to lock the row
329
+ with conn.cursor() as cur:
330
+ cur.execute(
331
+ "SELECT count FROM tempid_uses WHERE token_id = %s FOR UPDATE",
332
+ (token_id,),
333
+ )
334
+ row = cur.fetchone()
335
+ if row and row[0] >= max_uses:
336
+ conn.rollback()
337
+ return False
338
+ cur.execute(
339
+ "INSERT INTO tempid_uses (token_id, count, expires_at) VALUES (%s, 1, %s) "
340
+ "ON DUPLICATE KEY UPDATE count = count + 1",
341
+ (token_id, expires_at),
342
+ )
343
+ conn.commit()
344
+ return True
345
+ except Exception:
346
+ conn.rollback()
347
+ raise
348
+ finally:
349
+ conn.close()
350
+
351
+ def use_count(self, token_id: str) -> int:
352
+ conn = self._pool.connection()
353
+ try:
354
+ with conn.cursor() as cur:
355
+ cur.execute(
356
+ "SELECT count FROM tempid_uses WHERE token_id = %s", (token_id,)
357
+ )
358
+ row = cur.fetchone()
359
+ return row[0] if row else 0
360
+ finally:
361
+ conn.close()
362
+
363
+ def close(self) -> None:
364
+ self._pool.close()
365
+
366
+
367
+ class PostgreSQLBackend:
368
+ """
369
+ PostgreSQL backend. Safe for distributed, multi-worker production deployments.
370
+ Uses a connection pool and ``SELECT FOR UPDATE`` within a transaction
371
+ for atomic increments.
372
+
373
+ Args:
374
+ dsn: PostgreSQL connection DSN. Defaults to ``"postgresql://localhost/tempid"``.
375
+ min_conn: Minimum pool connections. Defaults to ``2``.
376
+ max_conn: Maximum pool connections. Defaults to ``10``.
377
+
378
+ Requires: ``pip install tempid[postgres]``
379
+ """
380
+
381
+ def __init__(
382
+ self,
383
+ dsn: str = "postgresql://localhost/tempid",
384
+ min_conn: int = 2,
385
+ max_conn: int = 10,
386
+ ) -> None:
387
+ try:
388
+ from psycopg2 import pool as pg_pool
389
+ except ImportError:
390
+ raise ImportError(
391
+ "PostgreSQLBackend requires psycopg2. "
392
+ "Install with: pip install tempid[postgres]"
393
+ )
394
+ self._pool = pg_pool.ThreadedConnectionPool(min_conn, max_conn, dsn=dsn)
395
+ self._setup()
396
+
397
+ def _setup(self) -> None:
398
+ conn = self._pool.getconn()
399
+ try:
400
+ with conn.cursor() as cur:
401
+ cur.execute("""
402
+ CREATE TABLE IF NOT EXISTS tempid_uses (
403
+ token_id TEXT PRIMARY KEY,
404
+ count INTEGER NOT NULL DEFAULT 0,
405
+ expires_at INTEGER NOT NULL DEFAULT 0
406
+ )
407
+ """)
408
+ conn.commit()
409
+ finally:
410
+ self._pool.putconn(conn)
411
+
412
+ def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
413
+ conn = self._pool.getconn()
414
+ try:
415
+ with conn.cursor() as cur:
416
+ cur.execute(
417
+ """
418
+ INSERT INTO tempid_uses (token_id, count, expires_at)
419
+ VALUES (%s, 1, %s)
420
+ ON CONFLICT (token_id) DO UPDATE
421
+ SET count = tempid_uses.count + 1
422
+ WHERE tempid_uses.count < %s
423
+ RETURNING count
424
+ """,
425
+ (token_id, expires_at, max_uses),
426
+ )
427
+ row = cur.fetchone()
428
+ conn.commit()
429
+ return row is not None
430
+ except Exception:
431
+ conn.rollback()
432
+ raise
433
+ finally:
434
+ self._pool.putconn(conn)
435
+
436
+ def use_count(self, token_id: str) -> int:
437
+ conn = self._pool.getconn()
438
+ try:
439
+ with conn.cursor() as cur:
440
+ cur.execute(
441
+ "SELECT count FROM tempid_uses WHERE token_id = %s", (token_id,)
442
+ )
443
+ row = cur.fetchone()
444
+ return row[0] if row else 0
445
+ finally:
446
+ self._pool.putconn(conn)
447
+
448
+ def close(self) -> None:
449
+ self._pool.closeall()