taplimit 0.1.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.
taplimit/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Small, typed, thread-safe in-process rate-limit decorator: N calls per period."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._decorator import rate_limit
6
+ from ._errors import RateLimitExceeded
7
+ from ._limiter import RateLimiter
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = ["RateLimitExceeded", "RateLimiter", "rate_limit"]
taplimit/_decorator.py ADDED
@@ -0,0 +1,44 @@
1
+ """The ``rate_limit`` decorator: the small, obvious entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from functools import wraps
8
+ from typing import ParamSpec, TypeVar
9
+
10
+ from ._limiter import RateLimiter
11
+
12
+ P = ParamSpec("P")
13
+ R = TypeVar("R")
14
+
15
+
16
+ def rate_limit(
17
+ calls: int,
18
+ period: float,
19
+ *,
20
+ on_limit: str = "block",
21
+ clock: Callable[[], float] = time.monotonic,
22
+ sleep: Callable[[float], None] = time.sleep,
23
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
24
+ """Limit the decorated function to ``calls`` invocations per ``period`` seconds.
25
+
26
+ With ``on_limit="block"`` (the default) a call over the limit waits until a slot
27
+ frees; with ``on_limit="raise"`` it raises RateLimitExceeded instead. All calls
28
+ to the decorated function share one sliding window. Inject ``clock`` and ``sleep``
29
+ to drive it from tests without real time passing.
30
+ """
31
+ if on_limit not in ("block", "raise"):
32
+ raise ValueError('on_limit must be "block" or "raise"')
33
+ blocking = on_limit == "block"
34
+ limiter = RateLimiter(calls, period, clock=clock, sleep=sleep)
35
+
36
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
37
+ @wraps(func)
38
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
39
+ limiter.acquire(blocking=blocking)
40
+ return func(*args, **kwargs)
41
+
42
+ return wrapper
43
+
44
+ return decorator
taplimit/_errors.py ADDED
@@ -0,0 +1,15 @@
1
+ """The error raised when a call would exceed the configured rate."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RateLimitExceeded(Exception):
7
+ """Raised when a call would exceed the rate and blocking is turned off.
8
+
9
+ The ``retry_after`` attribute holds the seconds until the next slot frees,
10
+ so a caller can decide how long to wait before trying again.
11
+ """
12
+
13
+ def __init__(self, retry_after: float) -> None:
14
+ self.retry_after = retry_after
15
+ super().__init__(f"rate limit exceeded, retry after {retry_after:.3f}s")
taplimit/_limiter.py ADDED
@@ -0,0 +1,103 @@
1
+ """A thread-safe, in-process, sliding-window rate limiter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections import deque
8
+ from collections.abc import Callable
9
+ from functools import wraps
10
+ from types import TracebackType
11
+ from typing import ParamSpec, TypeVar
12
+
13
+ from ._errors import RateLimitExceeded
14
+
15
+ P = ParamSpec("P")
16
+ R = TypeVar("R")
17
+
18
+
19
+ class RateLimiter:
20
+ """Allow at most ``calls`` acquisitions per ``period`` seconds.
21
+
22
+ The window slides: a slot frees exactly ``period`` seconds after the call that
23
+ took it, not on a shared clock boundary, so a burst never carries over. The
24
+ limiter is safe to share across threads and across functions, which lets several
25
+ callables draw on a single budget.
26
+
27
+ Inject ``clock`` (returns seconds) and ``sleep`` (waits seconds) to drive the
28
+ limiter from tests without real time passing.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ calls: int,
34
+ period: float,
35
+ *,
36
+ clock: Callable[[], float] = time.monotonic,
37
+ sleep: Callable[[float], None] = time.sleep,
38
+ ) -> None:
39
+ if calls < 1:
40
+ raise ValueError("calls must be at least 1")
41
+ if period <= 0:
42
+ raise ValueError("period must be greater than 0")
43
+ self._calls = calls
44
+ self._period = period
45
+ self._clock = clock
46
+ self._sleep = sleep
47
+ self._lock = threading.Lock()
48
+ self._hits: deque[float] = deque()
49
+
50
+ def _purge(self, now: float) -> None:
51
+ horizon = now - self._period
52
+ hits = self._hits
53
+ while hits and hits[0] <= horizon:
54
+ hits.popleft()
55
+
56
+ def try_acquire(self) -> bool:
57
+ """Take a slot without waiting. Return True if one was free, else False."""
58
+ with self._lock:
59
+ now = self._clock()
60
+ self._purge(now)
61
+ if len(self._hits) < self._calls:
62
+ self._hits.append(now)
63
+ return True
64
+ return False
65
+
66
+ def acquire(self, *, blocking: bool = True) -> None:
67
+ """Take a slot, waiting for one when ``blocking`` is True.
68
+
69
+ When ``blocking`` is False and every slot is taken, raise RateLimitExceeded
70
+ carrying the seconds until the next slot opens. A rejected call never
71
+ consumes a slot.
72
+ """
73
+ while True:
74
+ with self._lock:
75
+ now = self._clock()
76
+ self._purge(now)
77
+ if len(self._hits) < self._calls:
78
+ self._hits.append(now)
79
+ return
80
+ wait = self._hits[0] + self._period - now
81
+ if not blocking:
82
+ raise RateLimitExceeded(wait)
83
+ self._sleep(wait if wait > 0 else 0.0)
84
+
85
+ def __enter__(self) -> RateLimiter:
86
+ self.acquire()
87
+ return self
88
+
89
+ def __exit__(
90
+ self,
91
+ exc_type: type[BaseException] | None,
92
+ exc: BaseException | None,
93
+ tb: TracebackType | None,
94
+ ) -> None:
95
+ return None
96
+
97
+ def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
98
+ @wraps(func)
99
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
100
+ self.acquire()
101
+ return func(*args, **kwargs)
102
+
103
+ return wrapper
taplimit/py.typed ADDED
File without changes
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.5
2
+ Name: taplimit
3
+ Version: 0.1.0
4
+ Summary: Small, typed, thread-safe in-process rate-limit decorator: N calls per period, block or raise
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/taplimit
7
+ Project-URL: Issues, https://github.com/JaviLianes8/taplimit/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/taplimit/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: decorator,rate-limit,ratelimit,sliding-window,thread-safe,throttle
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: build; extra == 'dev'
22
+ Requires-Dist: mypy; extra == 'dev'
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: ruff; extra == 'dev'
25
+ Requires-Dist: twine; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # taplimit
29
+
30
+ Small, typed, thread-safe in-process rate-limit decorator: N calls per period, block or raise.
31
+
32
+ Zero dependencies, one obvious decorator, and a reusable limiter object when you need to
33
+ share a budget. The clock and sleep are injectable, so tests never wait on real time.
34
+
35
+ ## Install
36
+
37
+ ```
38
+ pip install taplimit
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ Cap a function at N calls per period. By default a call over the limit blocks until a
44
+ slot frees:
45
+
46
+ ```python
47
+ from taplimit import rate_limit
48
+
49
+ @rate_limit(calls=15, period=60)
50
+ def call_api() -> None:
51
+ ...
52
+ ```
53
+
54
+ Prefer to fail fast instead of waiting? Ask it to raise:
55
+
56
+ ```python
57
+ from taplimit import rate_limit, RateLimitExceeded
58
+
59
+ @rate_limit(calls=15, period=60, on_limit="raise")
60
+ def call_api() -> None:
61
+ ...
62
+
63
+ try:
64
+ call_api()
65
+ except RateLimitExceeded as exc:
66
+ print(f"slow down, retry in {exc.retry_after:.1f}s")
67
+ ```
68
+
69
+ Share one budget across several functions, or control slots by hand, with the limiter
70
+ object. It is a decorator, a context manager, and exposes `acquire` / `try_acquire`:
71
+
72
+ ```python
73
+ from taplimit import RateLimiter
74
+
75
+ limiter = RateLimiter(calls=100, period=60)
76
+
77
+ @limiter
78
+ def read() -> None: ...
79
+
80
+ @limiter
81
+ def write() -> None: ... # read and write draw on the same 100-per-minute budget
82
+
83
+ with limiter: # guard a block of code
84
+ do_work()
85
+
86
+ if limiter.try_acquire(): # never blocks; returns False when full
87
+ do_work()
88
+ ```
89
+
90
+ ### Testing without real time
91
+
92
+ `clock` (returns seconds) and `sleep` (waits seconds) are injectable, so a limiter runs
93
+ instantly under a fake clock:
94
+
95
+ ```python
96
+ now = [0.0]
97
+ slept: list[float] = []
98
+
99
+ limiter = RateLimiter(
100
+ calls=1, period=10,
101
+ clock=lambda: now[0],
102
+ sleep=lambda s: (slept.append(s), now.__setitem__(0, now[0] + s)),
103
+ )
104
+ ```
105
+
106
+ ## How it limits
107
+
108
+ taplimit uses a **sliding window**: it keeps the timestamps of recent calls and a slot
109
+ frees exactly `period` seconds after the call that took it. A burst never carries over a
110
+ fixed boundary the way a reset-every-minute counter does, so the rate you ask for is the
111
+ rate you get. All state is guarded by a lock, so one decorator or limiter is safe to share
112
+ across threads.
113
+
114
+ ## Why
115
+
116
+ The simple niche of "let this function run at most N times per period, in one process,
117
+ synchronously" is oddly unserved:
118
+
119
+ - [`ratelimit`](https://pypi.org/project/ratelimit/) popularised the `@limits(calls, period)`
120
+ shape and is still downloaded millions of times a month, but it has been **abandoned since
121
+ 2018** (last release 2.2.1). It also uses a fixed window and its threading story is thin.
122
+ - [`pyrate-limiter`](https://pypi.org/project/pyrate-limiter/) and
123
+ [`limits`](https://pypi.org/project/limits/) are maintained and capable, but they are
124
+ **backend-oriented and heavy**: buckets, optional SQLite / Redis stores, async machinery.
125
+ Overkill when all you want is a decorator on one function.
126
+ - [`limiter`](https://pypi.org/project/limiter/) is a token-bucket library with a different
127
+ `rate` / `capacity` API, not the plain calls-per-period shape.
128
+
129
+ taplimit is the small, modern, typed take: one decorator, a sliding window, thread-safe,
130
+ no dependencies, and a clock you can fake in tests. Reach for it when a single process needs
131
+ to stay under a rate and a full rate-limiting backend would be too much.
132
+
133
+ ## License
134
+
135
+ MIT. See [LICENSE](LICENSE).
136
+
137
+ ## Links
138
+
139
+ - Website: https://lianes8studio.com
140
+ - GitHub: https://github.com/JaviLianes8
141
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,9 @@
1
+ taplimit/__init__.py,sha256=nsOeDJxTsbZ1PU5TiVFhiLG2t4PNkfQiHHXgTG5XQi0,315
2
+ taplimit/_decorator.py,sha256=tjDTtAnMqFEuxnixm1SY13eBVygksJ4xfv44zAHeqkM,1417
3
+ taplimit/_errors.py,sha256=cVbmGNbJf17FpkV5J4zj7xbpBJ7o6JtnDDfJNBh92MA,545
4
+ taplimit/_limiter.py,sha256=cWGRz3E7Ip07bICPz42_TX4480Cepy9H6L2zw6WXYl0,3229
5
+ taplimit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ taplimit-0.1.0.dist-info/METADATA,sha256=X1nSHRmsSb7QwZqwSbik5stcD7dbwqf0MLKWPe430Ks,4606
7
+ taplimit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ taplimit-0.1.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
9
+ taplimit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.