slideguard 0.1.0__tar.gz
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.
- slideguard-0.1.0/PKG-INFO +4 -0
- slideguard-0.1.0/README.md +80 -0
- slideguard-0.1.0/pyproject.toml +11 -0
- slideguard-0.1.0/ratelimiter/__init__.py +5 -0
- slideguard-0.1.0/ratelimiter/core.py +12 -0
- slideguard-0.1.0/ratelimiter/decorator.py +35 -0
- slideguard-0.1.0/ratelimiter/exceptions.py +3 -0
- slideguard-0.1.0/setup.cfg +4 -0
- slideguard-0.1.0/slideguard.egg-info/PKG-INFO +4 -0
- slideguard-0.1.0/slideguard.egg-info/SOURCES.txt +11 -0
- slideguard-0.1.0/slideguard.egg-info/dependency_links.txt +1 -0
- slideguard-0.1.0/slideguard.egg-info/top_level.txt +1 -0
- slideguard-0.1.0/tests/test_ratelimiter.py +85 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# slideguard
|
|
2
|
+
|
|
3
|
+
A lightweight, open-source Python rate limiter.
|
|
4
|
+
|
|
5
|
+
- PyPI package name: `slideguard`
|
|
6
|
+
- Import path: `ratelimiter`
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- Timestamp-window based rate limiting
|
|
11
|
+
- Simple `@rate_limit(calls=N, per=seconds)` decorator
|
|
12
|
+
- Custom `RateLimitExceeded` exception
|
|
13
|
+
- Thread-safe decorator implementation
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
From PyPI (after publishing):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install slideguard
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
From source:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### 1) Use `is_allowed` directly
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import time
|
|
35
|
+
from ratelimiter import is_allowed
|
|
36
|
+
|
|
37
|
+
calls = []
|
|
38
|
+
|
|
39
|
+
allowed, calls = is_allowed(calls, max_calls=5, window=60)
|
|
40
|
+
if allowed:
|
|
41
|
+
calls.append(time.time())
|
|
42
|
+
print("Call accepted")
|
|
43
|
+
else:
|
|
44
|
+
print("Call blocked")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2) Use the `@rate_limit` decorator
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from ratelimiter import RateLimitExceeded, rate_limit
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@rate_limit(calls=5, per=60)
|
|
54
|
+
def send_event(payload: dict) -> str:
|
|
55
|
+
return f"sent: {payload['id']}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
result = send_event({"id": "evt-1"})
|
|
60
|
+
print(result)
|
|
61
|
+
except RateLimitExceeded as exc:
|
|
62
|
+
print(f"Blocked by rate limiter: {exc}")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Running tests
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python -m pytest -q
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Open source
|
|
72
|
+
|
|
73
|
+
This project is open source and contributions are welcome.
|
|
74
|
+
|
|
75
|
+
Suggested contribution flow:
|
|
76
|
+
|
|
77
|
+
1. Fork the repository
|
|
78
|
+
2. Create a feature branch
|
|
79
|
+
3. Add or update tests
|
|
80
|
+
4. Open a pull request
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from time import time
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def is_allowed(calls: list[float], max_calls: int, window: float) -> tuple[bool, list[float]]:
|
|
5
|
+
"""Return whether a new call is allowed and the cleaned list of calls.
|
|
6
|
+
|
|
7
|
+
Expired timestamps are those older than `window` seconds from now.
|
|
8
|
+
"""
|
|
9
|
+
now = time()
|
|
10
|
+
calls[:] = [timestamp for timestamp in calls if now - timestamp <= window]
|
|
11
|
+
allowed = len(calls) < max_calls
|
|
12
|
+
return allowed, calls
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from threading import Lock
|
|
3
|
+
from time import time
|
|
4
|
+
from typing import Callable, ParamSpec, TypeVar
|
|
5
|
+
|
|
6
|
+
from .core import is_allowed
|
|
7
|
+
from .exceptions import RateLimitExceeded
|
|
8
|
+
|
|
9
|
+
P = ParamSpec("P")
|
|
10
|
+
R = TypeVar("R")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rate_limit(calls: int, per: float) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
14
|
+
"""Limit function execution to `calls` within `per` seconds."""
|
|
15
|
+
|
|
16
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
17
|
+
timestamps: list[float] = []
|
|
18
|
+
lock = Lock()
|
|
19
|
+
|
|
20
|
+
@wraps(func)
|
|
21
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
22
|
+
with lock:
|
|
23
|
+
allowed, _ = is_allowed(timestamps, calls, per)
|
|
24
|
+
if not allowed:
|
|
25
|
+
raise RateLimitExceeded(
|
|
26
|
+
f"Rate limit exceeded: max {calls} calls per {per} seconds"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
timestamps.append(time())
|
|
30
|
+
return func(*args, **kwargs)
|
|
31
|
+
|
|
32
|
+
return wrapper
|
|
33
|
+
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
ratelimiter/__init__.py
|
|
4
|
+
ratelimiter/core.py
|
|
5
|
+
ratelimiter/decorator.py
|
|
6
|
+
ratelimiter/exceptions.py
|
|
7
|
+
slideguard.egg-info/PKG-INFO
|
|
8
|
+
slideguard.egg-info/SOURCES.txt
|
|
9
|
+
slideguard.egg-info/dependency_links.txt
|
|
10
|
+
slideguard.egg-info/top_level.txt
|
|
11
|
+
tests/test_ratelimiter.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ratelimiter
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from ratelimiter import RateLimitExceeded, rate_limit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_calls_within_limit_pass() -> None:
|
|
10
|
+
@rate_limit(calls=3, per=60)
|
|
11
|
+
def multiply_by_two(value: int) -> int:
|
|
12
|
+
return value * 2
|
|
13
|
+
|
|
14
|
+
with patch("ratelimiter.core.time", return_value=1000.0), patch(
|
|
15
|
+
"ratelimiter.decorator.time", return_value=1000.0
|
|
16
|
+
):
|
|
17
|
+
assert multiply_by_two(1) == 2
|
|
18
|
+
assert multiply_by_two(2) == 4
|
|
19
|
+
assert multiply_by_two(3) == 6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_call_exceeding_limit_raises() -> None:
|
|
23
|
+
@rate_limit(calls=2, per=60)
|
|
24
|
+
def ping() -> str:
|
|
25
|
+
return "pong"
|
|
26
|
+
|
|
27
|
+
with patch("ratelimiter.core.time", return_value=1000.0), patch(
|
|
28
|
+
"ratelimiter.decorator.time", return_value=1000.0
|
|
29
|
+
):
|
|
30
|
+
assert ping() == "pong"
|
|
31
|
+
assert ping() == "pong"
|
|
32
|
+
with pytest.raises(RateLimitExceeded):
|
|
33
|
+
ping()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_calls_allowed_again_after_window_expires() -> None:
|
|
37
|
+
@rate_limit(calls=2, per=10)
|
|
38
|
+
def ping() -> str:
|
|
39
|
+
return "pong"
|
|
40
|
+
|
|
41
|
+
with patch("ratelimiter.core.time", side_effect=[1000.0, 1001.0, 1002.0, 1012.0]), patch(
|
|
42
|
+
"ratelimiter.decorator.time", side_effect=[1000.0, 1001.0, 1012.0]
|
|
43
|
+
):
|
|
44
|
+
assert ping() == "pong"
|
|
45
|
+
assert ping() == "pong"
|
|
46
|
+
|
|
47
|
+
with pytest.raises(RateLimitExceeded):
|
|
48
|
+
ping()
|
|
49
|
+
|
|
50
|
+
assert ping() == "pong"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_thread_safety_only_n_succeed() -> None:
|
|
54
|
+
limit = 4
|
|
55
|
+
total_threads = 10
|
|
56
|
+
|
|
57
|
+
@rate_limit(calls=limit, per=60)
|
|
58
|
+
def guarded() -> str:
|
|
59
|
+
return "ok"
|
|
60
|
+
|
|
61
|
+
barrier = threading.Barrier(total_threads)
|
|
62
|
+
results: list[str] = []
|
|
63
|
+
|
|
64
|
+
def worker() -> None:
|
|
65
|
+
barrier.wait()
|
|
66
|
+
try:
|
|
67
|
+
guarded()
|
|
68
|
+
results.append("ok")
|
|
69
|
+
except RateLimitExceeded:
|
|
70
|
+
results.append("blocked")
|
|
71
|
+
|
|
72
|
+
threads = [threading.Thread(target=worker) for _ in range(total_threads)]
|
|
73
|
+
|
|
74
|
+
with patch("ratelimiter.core.time", return_value=1000.0), patch(
|
|
75
|
+
"ratelimiter.decorator.time", return_value=1000.0
|
|
76
|
+
):
|
|
77
|
+
for thread in threads:
|
|
78
|
+
thread.start()
|
|
79
|
+
|
|
80
|
+
for thread in threads:
|
|
81
|
+
thread.join(timeout=3)
|
|
82
|
+
|
|
83
|
+
assert all(not thread.is_alive() for thread in threads)
|
|
84
|
+
assert results.count("ok") == limit
|
|
85
|
+
assert results.count("blocked") == total_threads - limit
|