taplimit 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 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ buy_me_a_coffee: jlianesglrs
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0]
10
+
11
+ - First release: `rate_limit` decorator, `RateLimiter` object, and `RateLimitExceeded`.
12
+ - Sliding-window algorithm, thread-safe, zero dependencies.
13
+ - Block-or-raise behaviour, with injectable clock and sleep for tests.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/ratekeeper/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
taplimit-0.1.0/LICENSE ADDED
@@ -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.
@@ -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,114 @@
1
+ # taplimit
2
+
3
+ Small, typed, thread-safe in-process rate-limit decorator: N calls per period, block or raise.
4
+
5
+ Zero dependencies, one obvious decorator, and a reusable limiter object when you need to
6
+ share a budget. The clock and sleep are injectable, so tests never wait on real time.
7
+
8
+ ## Install
9
+
10
+ ```
11
+ pip install taplimit
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Cap a function at N calls per period. By default a call over the limit blocks until a
17
+ slot frees:
18
+
19
+ ```python
20
+ from taplimit import rate_limit
21
+
22
+ @rate_limit(calls=15, period=60)
23
+ def call_api() -> None:
24
+ ...
25
+ ```
26
+
27
+ Prefer to fail fast instead of waiting? Ask it to raise:
28
+
29
+ ```python
30
+ from taplimit import rate_limit, RateLimitExceeded
31
+
32
+ @rate_limit(calls=15, period=60, on_limit="raise")
33
+ def call_api() -> None:
34
+ ...
35
+
36
+ try:
37
+ call_api()
38
+ except RateLimitExceeded as exc:
39
+ print(f"slow down, retry in {exc.retry_after:.1f}s")
40
+ ```
41
+
42
+ Share one budget across several functions, or control slots by hand, with the limiter
43
+ object. It is a decorator, a context manager, and exposes `acquire` / `try_acquire`:
44
+
45
+ ```python
46
+ from taplimit import RateLimiter
47
+
48
+ limiter = RateLimiter(calls=100, period=60)
49
+
50
+ @limiter
51
+ def read() -> None: ...
52
+
53
+ @limiter
54
+ def write() -> None: ... # read and write draw on the same 100-per-minute budget
55
+
56
+ with limiter: # guard a block of code
57
+ do_work()
58
+
59
+ if limiter.try_acquire(): # never blocks; returns False when full
60
+ do_work()
61
+ ```
62
+
63
+ ### Testing without real time
64
+
65
+ `clock` (returns seconds) and `sleep` (waits seconds) are injectable, so a limiter runs
66
+ instantly under a fake clock:
67
+
68
+ ```python
69
+ now = [0.0]
70
+ slept: list[float] = []
71
+
72
+ limiter = RateLimiter(
73
+ calls=1, period=10,
74
+ clock=lambda: now[0],
75
+ sleep=lambda s: (slept.append(s), now.__setitem__(0, now[0] + s)),
76
+ )
77
+ ```
78
+
79
+ ## How it limits
80
+
81
+ taplimit uses a **sliding window**: it keeps the timestamps of recent calls and a slot
82
+ frees exactly `period` seconds after the call that took it. A burst never carries over a
83
+ fixed boundary the way a reset-every-minute counter does, so the rate you ask for is the
84
+ rate you get. All state is guarded by a lock, so one decorator or limiter is safe to share
85
+ across threads.
86
+
87
+ ## Why
88
+
89
+ The simple niche of "let this function run at most N times per period, in one process,
90
+ synchronously" is oddly unserved:
91
+
92
+ - [`ratelimit`](https://pypi.org/project/ratelimit/) popularised the `@limits(calls, period)`
93
+ shape and is still downloaded millions of times a month, but it has been **abandoned since
94
+ 2018** (last release 2.2.1). It also uses a fixed window and its threading story is thin.
95
+ - [`pyrate-limiter`](https://pypi.org/project/pyrate-limiter/) and
96
+ [`limits`](https://pypi.org/project/limits/) are maintained and capable, but they are
97
+ **backend-oriented and heavy**: buckets, optional SQLite / Redis stores, async machinery.
98
+ Overkill when all you want is a decorator on one function.
99
+ - [`limiter`](https://pypi.org/project/limiter/) is a token-bucket library with a different
100
+ `rate` / `capacity` API, not the plain calls-per-period shape.
101
+
102
+ taplimit is the small, modern, typed take: one decorator, a sliding window, thread-safe,
103
+ no dependencies, and a clock you can fake in tests. Reach for it when a single process needs
104
+ to stay under a rate and a full rate-limiting backend would be too much.
105
+
106
+ ## License
107
+
108
+ MIT. See [LICENSE](LICENSE).
109
+
110
+ ## Links
111
+
112
+ - Website: https://lianes8studio.com
113
+ - GitHub: https://github.com/JaviLianes8
114
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "taplimit"
7
+ version = "0.1.0"
8
+ description = "Small, typed, thread-safe in-process rate-limit decorator: N calls per period, block or raise"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["rate-limit", "ratelimit", "throttle", "decorator", "thread-safe", "sliding-window"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://lianes8studio.com"
28
+ Repository = "https://github.com/JaviLianes8/taplimit"
29
+ Issues = "https://github.com/JaviLianes8/taplimit/issues"
30
+ Changelog = "https://github.com/JaviLianes8/taplimit/blob/main/CHANGELOG.md"
31
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
32
+ Funding = "https://buymeacoffee.com/jlianesglrs"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/taplimit"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+ target-version = "py310"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
43
+
44
+ [tool.mypy]
45
+ python_version = "3.10"
46
+ strict = true
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+ pythonpath = ["src"]
51
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,7 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ $ErrorActionPreference = "Stop"
3
+
4
+ ruff format --check src tests
5
+ ruff check src tests
6
+ mypy src tests
7
+ pytest
@@ -0,0 +1,26 @@
1
+ # Build and upload to PyPI. Run scripts/check.ps1 first.
2
+ # The name must not already exist on PyPI; the token is read from a secret store, never git.
3
+ $ErrorActionPreference = "Stop"
4
+
5
+ try {
6
+ Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/taplimit/json" | Out-Null
7
+ throw "The name 'taplimit' already exists on PyPI. Choose another before publishing."
8
+ } catch [System.Net.WebException], [Microsoft.PowerShell.Commands.HttpResponseException] {
9
+ if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
10
+ }
11
+
12
+ $token = $env:PYPI_TOKEN
13
+ if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
14
+ $token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
15
+ }
16
+ if (-not $token) {
17
+ throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
18
+ }
19
+
20
+ Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
21
+ python -m build
22
+ python -m twine check dist/*
23
+
24
+ $env:TWINE_USERNAME = "__token__"
25
+ $env:TWINE_PASSWORD = $token
26
+ try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }
@@ -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"]
@@ -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
@@ -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")
@@ -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
File without changes
@@ -0,0 +1,21 @@
1
+ """A deterministic clock and sleep pair so tests never wait on real time."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class FakeClock:
7
+ """A fake monotonic clock whose ``sleep`` simply advances the clock."""
8
+
9
+ def __init__(self, start: float = 0.0) -> None:
10
+ self.now = start
11
+ self.slept: list[float] = []
12
+
13
+ def time(self) -> float:
14
+ return self.now
15
+
16
+ def sleep(self, seconds: float) -> None:
17
+ self.slept.append(seconds)
18
+ self.now += seconds
19
+
20
+ def advance(self, seconds: float) -> None:
21
+ self.now += seconds
@@ -0,0 +1,26 @@
1
+ import importlib.resources
2
+
3
+ import taplimit
4
+
5
+ # The public API. Add every exported name here as the library grows; this list is the
6
+ # contract and the test below fails if the module and this list disagree.
7
+ PUBLIC_API: list[str] = ["RateLimitExceeded", "RateLimiter", "rate_limit"]
8
+
9
+
10
+ def test_all_lists_exactly_the_public_api() -> None:
11
+ assert sorted(taplimit.__all__) == sorted(PUBLIC_API)
12
+
13
+
14
+ def test_every_exported_name_resolves() -> None:
15
+ for name in taplimit.__all__:
16
+ assert hasattr(taplimit, name)
17
+
18
+
19
+ def test_version_is_a_three_part_string() -> None:
20
+ parts = taplimit.__version__.split(".")
21
+ assert len(parts) == 3
22
+ assert all(part.isdigit() for part in parts)
23
+
24
+
25
+ def test_the_package_ships_type_information() -> None:
26
+ assert importlib.resources.files("taplimit").joinpath("py.typed").is_file()
@@ -0,0 +1,59 @@
1
+ """Behaviour tests for the rate_limit decorator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from _fakeclock import FakeClock
7
+
8
+ from taplimit import RateLimitExceeded, rate_limit
9
+
10
+
11
+ def test_blocks_by_default_until_a_slot_frees() -> None:
12
+ clock = FakeClock()
13
+
14
+ @rate_limit(2, 10, clock=clock.time, sleep=clock.sleep)
15
+ def work() -> str:
16
+ return "ok"
17
+
18
+ assert work() == "ok"
19
+ assert work() == "ok"
20
+ assert work() == "ok" # third call waits
21
+ assert clock.slept == [10.0]
22
+
23
+
24
+ def test_raise_mode_raises_instead_of_waiting() -> None:
25
+ clock = FakeClock()
26
+
27
+ @rate_limit(1, 10, on_limit="raise", clock=clock.time, sleep=clock.sleep)
28
+ def work() -> str:
29
+ return "ok"
30
+
31
+ assert work() == "ok"
32
+ with pytest.raises(RateLimitExceeded):
33
+ work()
34
+ assert clock.slept == []
35
+
36
+
37
+ def test_preserves_arguments_and_return_value() -> None:
38
+ clock = FakeClock()
39
+
40
+ @rate_limit(10, 10, clock=clock.time, sleep=clock.sleep)
41
+ def add(a: int, b: int = 0) -> int:
42
+ return a + b
43
+
44
+ assert add(2, b=3) == 5
45
+
46
+
47
+ def test_preserves_the_wrapped_name_and_docstring() -> None:
48
+ @rate_limit(1, 1)
49
+ def greet() -> str:
50
+ """Say hello."""
51
+ return "hi"
52
+
53
+ assert greet.__name__ == "greet"
54
+ assert greet.__doc__ == "Say hello."
55
+
56
+
57
+ def test_rejects_an_unknown_on_limit_value() -> None:
58
+ with pytest.raises(ValueError):
59
+ rate_limit(1, 10, on_limit="explode")
@@ -0,0 +1,146 @@
1
+ """Behaviour tests for the RateLimiter primitive."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+
7
+ import pytest
8
+ from _fakeclock import FakeClock
9
+
10
+ from taplimit import RateLimiter, RateLimitExceeded
11
+
12
+
13
+ def test_allows_up_to_the_limit_without_waiting() -> None:
14
+ clock = FakeClock()
15
+ limiter = RateLimiter(3, 60, clock=clock.time, sleep=clock.sleep)
16
+
17
+ for _ in range(3):
18
+ limiter.acquire()
19
+
20
+ assert clock.slept == []
21
+
22
+
23
+ def test_blocks_until_the_oldest_slot_frees() -> None:
24
+ clock = FakeClock()
25
+ limiter = RateLimiter(2, 10, clock=clock.time, sleep=clock.sleep)
26
+
27
+ limiter.acquire() # t=0
28
+ clock.advance(4)
29
+ limiter.acquire() # t=4, window now full
30
+ limiter.acquire() # must wait for the t=0 slot to free at t=10
31
+
32
+ assert clock.slept == [6.0]
33
+ assert clock.now == 10.0
34
+
35
+
36
+ def test_try_acquire_reports_free_and_full() -> None:
37
+ clock = FakeClock()
38
+ limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
39
+
40
+ assert limiter.try_acquire() is True
41
+ assert limiter.try_acquire() is False
42
+ clock.advance(10)
43
+ assert limiter.try_acquire() is True
44
+
45
+
46
+ def test_try_acquire_does_not_wait() -> None:
47
+ clock = FakeClock()
48
+ limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
49
+
50
+ limiter.try_acquire()
51
+ limiter.try_acquire()
52
+
53
+ assert clock.slept == []
54
+
55
+
56
+ def test_non_blocking_acquire_raises_with_retry_after() -> None:
57
+ clock = FakeClock()
58
+ limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
59
+
60
+ limiter.acquire()
61
+ clock.advance(3)
62
+ with pytest.raises(RateLimitExceeded) as info:
63
+ limiter.acquire(blocking=False)
64
+
65
+ assert info.value.retry_after == pytest.approx(7.0)
66
+
67
+
68
+ def test_rejected_call_does_not_consume_a_slot() -> None:
69
+ clock = FakeClock()
70
+ limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
71
+
72
+ limiter.acquire()
73
+ with pytest.raises(RateLimitExceeded):
74
+ limiter.acquire(blocking=False)
75
+ clock.advance(10)
76
+
77
+ assert limiter.try_acquire() is True
78
+
79
+
80
+ def test_window_slides_rather_than_resetting() -> None:
81
+ clock = FakeClock()
82
+ limiter = RateLimiter(2, 10, clock=clock.time, sleep=clock.sleep)
83
+
84
+ limiter.acquire() # t=0
85
+ limiter.acquire() # t=0
86
+ clock.advance(10) # the two slots free exactly now
87
+ assert limiter.try_acquire() is True
88
+ assert limiter.try_acquire() is True
89
+
90
+
91
+ def test_works_as_a_decorator() -> None:
92
+ clock = FakeClock()
93
+ limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
94
+ calls = 0
95
+
96
+ @limiter
97
+ def ping() -> int:
98
+ nonlocal calls
99
+ calls += 1
100
+ return calls
101
+
102
+ assert ping() == 1
103
+ clock.advance(10)
104
+ assert ping() == 2
105
+
106
+
107
+ def test_works_as_a_context_manager() -> None:
108
+ clock = FakeClock()
109
+ limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
110
+
111
+ with limiter:
112
+ pass
113
+ clock.advance(10)
114
+ with limiter:
115
+ pass
116
+
117
+ assert clock.slept == []
118
+
119
+
120
+ def test_rejects_non_positive_configuration() -> None:
121
+ with pytest.raises(ValueError):
122
+ RateLimiter(0, 10)
123
+ with pytest.raises(ValueError):
124
+ RateLimiter(1, 0)
125
+
126
+
127
+ def test_is_thread_safe_under_contention() -> None:
128
+ clock = FakeClock()
129
+ # Real sleep of zero keeps the test fast while exercising the lock.
130
+ limiter = RateLimiter(50, 1000, clock=clock.time)
131
+ taken = 0
132
+ lock = threading.Lock()
133
+
134
+ def worker() -> None:
135
+ nonlocal taken
136
+ if limiter.try_acquire():
137
+ with lock:
138
+ taken += 1
139
+
140
+ threads = [threading.Thread(target=worker) for _ in range(200)]
141
+ for thread in threads:
142
+ thread.start()
143
+ for thread in threads:
144
+ thread.join()
145
+
146
+ assert taken == 50