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.
@@ -0,0 +1,4 @@
1
+ Metadata-Version: 2.4
2
+ Name: slideguard
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
@@ -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,11 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "slideguard"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.9"
9
+
10
+ [tool.setuptools.packages.find]
11
+ include = ["ratelimiter*"]
@@ -0,0 +1,5 @@
1
+ from .core import is_allowed
2
+ from .decorator import rate_limit
3
+ from .exceptions import RateLimitExceeded
4
+
5
+ __all__ = ["is_allowed", "rate_limit", "RateLimitExceeded"]
@@ -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,3 @@
1
+ class RateLimitExceeded(Exception):
2
+ """Raised when a call is blocked by the rate limiter."""
3
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ Metadata-Version: 2.4
2
+ Name: slideguard
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
@@ -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
+ 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