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 +54 -0
- tempid/async_backends.py +428 -0
- tempid/backends.py +449 -0
- tempid/core.py +725 -0
- tempid/exceptions.py +27 -0
- tempid/py.typed +0 -0
- tempid-2.0.0.dist-info/METADATA +372 -0
- tempid-2.0.0.dist-info/RECORD +11 -0
- tempid-2.0.0.dist-info/WHEEL +5 -0
- tempid-2.0.0.dist-info/licenses/LICENSE +21 -0
- tempid-2.0.0.dist-info/top_level.txt +1 -0
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()
|