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/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ from .core import TempID, configure, teardown, teardown_async
2
+ from .exceptions import (
3
+ TempIDError,
4
+ TempIDExpiredError,
5
+ TempIDFormatError,
6
+ TempIDPayloadTooLargeError,
7
+ TempIDRevokedError,
8
+ TempIDTamperedError,
9
+ )
10
+ from .backends import (
11
+ MemoryBackend,
12
+ SQLiteBackend,
13
+ RedisBackend,
14
+ MongoBackend,
15
+ MySQLBackend,
16
+ PostgreSQLBackend,
17
+ )
18
+ from .async_backends import (
19
+ AsyncMemoryBackend,
20
+ AsyncSQLiteBackend,
21
+ AsyncRedisBackend,
22
+ AsyncMongoBackend,
23
+ AsyncPostgreSQLBackend,
24
+ AsyncMySQLBackend,
25
+ )
26
+
27
+ __all__ = [
28
+ "TempID",
29
+ "configure",
30
+ "teardown",
31
+ "teardown_async",
32
+ # exceptions
33
+ "TempIDError",
34
+ "TempIDExpiredError",
35
+ "TempIDFormatError",
36
+ "TempIDPayloadTooLargeError",
37
+ "TempIDRevokedError",
38
+ "TempIDTamperedError",
39
+ # backends
40
+ "MemoryBackend",
41
+ "SQLiteBackend",
42
+ "RedisBackend",
43
+ "MongoBackend",
44
+ "MySQLBackend",
45
+ "PostgreSQLBackend",
46
+ "AsyncMemoryBackend",
47
+ "AsyncSQLiteBackend",
48
+ "AsyncRedisBackend",
49
+ "AsyncMongoBackend",
50
+ "AsyncPostgreSQLBackend",
51
+ "AsyncMySQLBackend",
52
+ ]
53
+ __version__ = "2.0.0"
54
+ __author__ = "Rahul Patel"
@@ -0,0 +1,428 @@
1
+ """
2
+ tempid.async_backends — Pluggable async use-count backends for max_uses tokens.
3
+
4
+ Usage::
5
+
6
+ from tempid import configure
7
+ from tempid.async_backends import AsyncRedisBackend
8
+
9
+ configure(store=AsyncRedisBackend("redis://localhost:6379"))
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import time
16
+ from typing import Any
17
+
18
+ from .backends import _LUA_INCR
19
+
20
+
21
+ class AsyncMemoryBackend:
22
+ """
23
+ In-process memory backend (Async). Zero dependencies.
24
+ Suitable for single-worker ASGI deployments (FastAPI dev server).
25
+ WARNING: Not for multi-worker production.
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ self._lock: asyncio.Lock | None = None
30
+ self._store: dict[str, int] = {}
31
+ self._expiry: dict[str, int] = {}
32
+
33
+ def _get_lock(self) -> asyncio.Lock:
34
+ if self._lock is None:
35
+ self._lock = asyncio.Lock()
36
+ return self._lock
37
+
38
+ async def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
39
+ async with self._get_lock():
40
+ self._gc()
41
+ current = self._store.get(token_id, 0)
42
+ if current >= max_uses:
43
+ return False
44
+ self._store[token_id] = current + 1
45
+ if expires_at:
46
+ self._expiry[token_id] = expires_at
47
+ return True
48
+
49
+ async def use_count(self, token_id: str) -> int:
50
+ async with self._get_lock():
51
+ return self._store.get(token_id, 0)
52
+
53
+ def _gc(self) -> None:
54
+ """Lazy garbage collection — remove expired token entries."""
55
+ now = int(time.time())
56
+ expired = [tid for tid, exp in self._expiry.items() if exp < now]
57
+ for tid in expired:
58
+ self._store.pop(tid, None)
59
+ self._expiry.pop(tid, None)
60
+
61
+ async def aclose(self) -> None:
62
+ """No-op for AsyncMemoryBackend."""
63
+ pass
64
+
65
+
66
+ class AsyncSQLiteBackend:
67
+ """
68
+ SQLite backend (Async). Persists use counts across server restarts.
69
+ Requires: ``pip install tempid[async-sqlite]`` (installs aiosqlite).
70
+ """
71
+
72
+ def __init__(self, path: str = "tempid_uses.db") -> None:
73
+ try:
74
+ import aiosqlite # type: ignore # noqa: F401
75
+ except ImportError:
76
+ raise ImportError(
77
+ "AsyncSQLiteBackend requires aiosqlite. "
78
+ "Install with: pip install tempid[async-sqlite]"
79
+ )
80
+ self._path = path
81
+ self._lock: asyncio.Lock | None = None
82
+ self._pool_created = False
83
+ self._conn: Any = None
84
+
85
+ def _get_lock(self) -> asyncio.Lock:
86
+ if self._lock is None:
87
+ self._lock = asyncio.Lock()
88
+ return self._lock
89
+
90
+ async def _setup(self) -> None:
91
+ if self._pool_created:
92
+ return
93
+
94
+ import aiosqlite
95
+ try:
96
+ self._conn = await aiosqlite.connect(self._path, timeout=10)
97
+ await self._conn.execute("PRAGMA journal_mode=WAL")
98
+ await self._conn.execute("""
99
+ CREATE TABLE IF NOT EXISTS tempid_uses (
100
+ token_id TEXT PRIMARY KEY,
101
+ count INTEGER NOT NULL DEFAULT 0,
102
+ expires_at INTEGER NOT NULL DEFAULT 0
103
+ )
104
+ """)
105
+ await self._conn.commit()
106
+ self._pool_created = True
107
+ except Exception:
108
+ if self._conn:
109
+ await self._conn.close()
110
+ self._conn = None
111
+ raise
112
+
113
+ async def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
114
+ async with self._get_lock():
115
+ await self._setup()
116
+ await self._cleanup()
117
+
118
+ async with self._conn.execute(
119
+ "SELECT count FROM tempid_uses WHERE token_id = ?", (token_id,)
120
+ ) as cursor:
121
+ row = await cursor.fetchone()
122
+
123
+ current = row[0] if row else 0
124
+ if current >= max_uses:
125
+ return False
126
+
127
+ await self._conn.execute(
128
+ "INSERT INTO tempid_uses (token_id, count, expires_at) VALUES (?, 1, ?) "
129
+ "ON CONFLICT(token_id) DO UPDATE SET count = count + 1",
130
+ (token_id, expires_at),
131
+ )
132
+ await self._conn.commit()
133
+ return True
134
+
135
+ async def use_count(self, token_id: str) -> int:
136
+ async with self._get_lock():
137
+ await self._setup()
138
+ async with self._conn.execute(
139
+ "SELECT count FROM tempid_uses WHERE token_id = ?", (token_id,)
140
+ ) as cursor:
141
+ row = await cursor.fetchone()
142
+ return row[0] if row else 0
143
+
144
+ async def aclose(self) -> None:
145
+ if self._conn:
146
+ await self._conn.close()
147
+ self._conn = None
148
+
149
+ async def _cleanup(self) -> None:
150
+ await self._conn.execute(
151
+ "DELETE FROM tempid_uses WHERE expires_at > 0 AND expires_at < ?",
152
+ (int(time.time()),),
153
+ )
154
+ await self._conn.commit()
155
+
156
+
157
+ class AsyncRedisBackend:
158
+ """
159
+ Redis backend (Async). Safe for distributed, multi-worker deployments.
160
+ Requires: ``pip install tempid[async-redis]``
161
+ """
162
+
163
+ def __init__(self, uri: str = "redis://localhost:6379/0", prefix: str = "tempid:") -> None:
164
+ try:
165
+ import redis.asyncio as redis # type: ignore
166
+ except ImportError:
167
+ raise ImportError(
168
+ "AsyncRedisBackend requires redis-py. "
169
+ "Install with: pip install tempid[async-redis]"
170
+ )
171
+ self._pool = redis.from_url(uri, decode_responses=True)
172
+ self.prefix = prefix
173
+ # Pre-register the script hash for performance
174
+ self._incr_script = self._pool.register_script(_LUA_INCR)
175
+
176
+ async def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
177
+ key = f"{self.prefix}{token_id}"
178
+ # Lua script handles both INCR and TTL atomically to avoid race conditions
179
+ count = await self._incr_script(keys=[key], args=[max_uses, expires_at])
180
+
181
+ # Redis Lua script returns 0 if limit exceeded
182
+ if count == 0:
183
+ return False
184
+
185
+ return True
186
+
187
+ async def use_count(self, token_id: str) -> int:
188
+ key = f"{self.prefix}{token_id}"
189
+ val = await self._pool.get(key)
190
+ return int(val) if val else 0
191
+
192
+ async def aclose(self) -> None:
193
+ if self._pool:
194
+ await self._pool.aclose()
195
+
196
+
197
+ class AsyncMongoBackend:
198
+ """
199
+ MongoDB backend (Async). Safe for distributed, multi-worker deployments.
200
+ Uses ``find_one_and_update`` for atomic document-level operations.
201
+ Requires: ``pip install tempid[async-mongo]``
202
+ """
203
+
204
+ def __init__(self, uri: str = "mongodb://localhost:27017", db: str = "tempid") -> None:
205
+ try:
206
+ from motor.motor_asyncio import AsyncIOMotorClient
207
+ except ImportError:
208
+ raise ImportError(
209
+ "AsyncMongoBackend requires motor. "
210
+ "Install with: pip install tempid[async-mongo]"
211
+ )
212
+ self._client: Any = AsyncIOMotorClient(uri)
213
+ self._col: Any = self._client[db]["uses"]
214
+ self._setup_lock = asyncio.Lock()
215
+ self._setup_done = False
216
+
217
+ async def _setup(self) -> None:
218
+ if self._setup_done:
219
+ return
220
+ async with self._setup_lock:
221
+ if self._setup_done:
222
+ return
223
+ await self._col.create_index("token_id", unique=True)
224
+ await self._col.create_index("expires_at", expireAfterSeconds=0, sparse=True)
225
+ self._setup_done = True
226
+
227
+ async def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
228
+ from pymongo.errors import DuplicateKeyError
229
+ await self._setup()
230
+ try:
231
+ await self._col.insert_one({"token_id": token_id, "count": 1, "expires_at": expires_at})
232
+ return True
233
+ except DuplicateKeyError:
234
+ result = await self._col.find_one_and_update(
235
+ {"token_id": token_id, "count": {"$lt": max_uses}},
236
+ {"$inc": {"count": 1}},
237
+ return_document=True,
238
+ )
239
+ return result is not None
240
+
241
+ async def use_count(self, token_id: str) -> int:
242
+ await self._setup()
243
+ doc = await self._col.find_one({"token_id": token_id})
244
+ return doc["count"] if doc else 0
245
+
246
+ async def aclose(self) -> None:
247
+ """Motor closes the client automatically on event loop teardown, but can be closed explicitly."""
248
+ if self._client:
249
+ self._client.close()
250
+
251
+
252
+ class AsyncPostgreSQLBackend:
253
+ """
254
+ PostgreSQL backend (Async). Uses asyncpg.
255
+ Requires: ``pip install tempid[async-postgres]``
256
+ """
257
+
258
+ def __init__(
259
+ self,
260
+ dsn: str = "postgresql://localhost/tempid",
261
+ min_conn: int = 2,
262
+ max_conn: int = 10,
263
+ ) -> None:
264
+ try:
265
+ import asyncpg # type: ignore # noqa: F401
266
+ except ImportError:
267
+ raise ImportError(
268
+ "AsyncPostgreSQLBackend requires asyncpg. "
269
+ "Install with: pip install tempid[async-postgres]"
270
+ )
271
+ self.dsn = dsn
272
+ self.min_conn = min_conn
273
+ self.max_conn = max_conn
274
+ self._pool = None
275
+ self._setup_lock = asyncio.Lock()
276
+
277
+ async def _get_pool(self):
278
+ import asyncpg
279
+ if self._pool is None:
280
+ async with self._setup_lock:
281
+ if self._pool is None:
282
+ # 1. Create pool in local variable
283
+ pool = await asyncpg.create_pool(
284
+ self.dsn, min_size=self.min_conn, max_size=self.max_conn
285
+ )
286
+ # 2. Setup tables using the local pool
287
+ async with pool.acquire() as conn:
288
+ await conn.execute("""
289
+ CREATE TABLE IF NOT EXISTS tempid_uses (
290
+ token_id TEXT PRIMARY KEY,
291
+ count INTEGER NOT NULL DEFAULT 0,
292
+ expires_at INTEGER NOT NULL DEFAULT 0
293
+ )
294
+ """)
295
+ # 3. Assign self._pool LAST to prevent race condition leaks
296
+ self._pool = pool
297
+ return self._pool
298
+
299
+ async def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
300
+ pool = await self._get_pool()
301
+ async with pool.acquire() as conn:
302
+ async with conn.transaction():
303
+ row = await conn.fetchrow(
304
+ """
305
+ INSERT INTO tempid_uses (token_id, count, expires_at)
306
+ VALUES ($1, 1, $2)
307
+ ON CONFLICT (token_id) DO UPDATE
308
+ SET count = tempid_uses.count + 1
309
+ WHERE tempid_uses.count < $3
310
+ RETURNING count
311
+ """,
312
+ token_id, expires_at, max_uses
313
+ )
314
+ return row is not None
315
+
316
+ async def use_count(self, token_id: str) -> int:
317
+ pool = await self._get_pool()
318
+ async with pool.acquire() as conn:
319
+ row = await conn.fetchrow("SELECT count FROM tempid_uses WHERE token_id = $1", token_id)
320
+ return row["count"] if row else 0
321
+
322
+ async def aclose(self) -> None:
323
+ if self._pool:
324
+ await self._pool.close()
325
+
326
+
327
+ class AsyncMySQLBackend:
328
+ """
329
+ MySQL backend (Async). Uses aiomysql.
330
+ Requires: ``pip install tempid[async-mysql]``
331
+ """
332
+
333
+ def __init__(
334
+ self,
335
+ host: str = "localhost",
336
+ port: int = 3306,
337
+ user: str = "root",
338
+ password: str = "",
339
+ db: str = "tempid",
340
+ max_connections: int = 10,
341
+ ) -> None:
342
+ try:
343
+ import aiomysql # type: ignore # noqa: F401
344
+ except ImportError:
345
+ raise ImportError(
346
+ "AsyncMySQLBackend requires aiomysql. "
347
+ "Install with: pip install tempid[async-mysql]"
348
+ )
349
+ self.host = host
350
+ self.port = port
351
+ self.user = user
352
+ self.password = password
353
+ self.db = db
354
+ self.max_connections = max_connections
355
+ self._pool = None
356
+ self._setup_lock = asyncio.Lock()
357
+
358
+ async def _get_pool(self):
359
+ import aiomysql
360
+ if self._pool is None:
361
+ async with self._setup_lock:
362
+ if self._pool is None:
363
+ # 1. Create pool in local variable with explicit autocommit=False
364
+ pool = await aiomysql.create_pool(
365
+ host=self.host,
366
+ port=self.port,
367
+ user=self.user,
368
+ password=self.password,
369
+ db=self.db,
370
+ minsize=1,
371
+ maxsize=self.max_connections,
372
+ autocommit=False,
373
+ )
374
+ # 2. Setup tables using the local pool
375
+ async with pool.acquire() as conn:
376
+ async with conn.cursor() as cur:
377
+ await cur.execute("""
378
+ CREATE TABLE IF NOT EXISTS tempid_uses (
379
+ token_id VARCHAR(64) PRIMARY KEY,
380
+ count INT NOT NULL DEFAULT 0,
381
+ expires_at INT NOT NULL DEFAULT 0
382
+ )
383
+ """)
384
+ await conn.commit()
385
+ # 3. Assign self._pool LAST to prevent race condition leaks
386
+ self._pool = pool
387
+ return self._pool
388
+
389
+ async def increment_use(self, token_id: str, max_uses: int, expires_at: int = 0) -> bool:
390
+ pool = await self._get_pool()
391
+ async with pool.acquire() as conn:
392
+ try:
393
+ async with conn.cursor() as cur:
394
+ await cur.execute(
395
+ "SELECT count FROM tempid_uses WHERE token_id = %s FOR UPDATE",
396
+ (token_id,)
397
+ )
398
+ row = await cur.fetchone()
399
+
400
+ if row and row[0] >= max_uses:
401
+ await conn.rollback()
402
+ return False
403
+
404
+ await cur.execute(
405
+ "INSERT INTO tempid_uses (token_id, count, expires_at) VALUES (%s, 1, %s) "
406
+ "ON DUPLICATE KEY UPDATE count = count + 1",
407
+ (token_id, expires_at),
408
+ )
409
+ await conn.commit()
410
+ return True
411
+ except Exception:
412
+ await conn.rollback()
413
+ raise
414
+
415
+ async def use_count(self, token_id: str) -> int:
416
+ pool = await self._get_pool()
417
+ async with pool.acquire() as conn:
418
+ async with conn.cursor() as cur:
419
+ await cur.execute(
420
+ "SELECT count FROM tempid_uses WHERE token_id = %s", (token_id,)
421
+ )
422
+ row = await cur.fetchone()
423
+ return row[0] if row else 0
424
+
425
+ async def aclose(self) -> None:
426
+ if self._pool:
427
+ self._pool.close()
428
+ await self._pool.wait_closed()