limits 4.0.0__py3-none-any.whl → 4.1__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.
- limits/_version.py +3 -3
- limits/aio/storage/__init__.py +2 -1
- limits/aio/storage/base.py +70 -24
- limits/aio/storage/etcd.py +6 -2
- limits/aio/storage/memcached.py +157 -33
- limits/aio/storage/memory.py +98 -13
- limits/aio/storage/mongodb.py +233 -19
- limits/aio/storage/redis.py +100 -15
- limits/aio/strategies.py +122 -1
- limits/limits.py +10 -11
- limits/resources/redis/lua_scripts/acquire_sliding_window.lua +45 -0
- limits/resources/redis/lua_scripts/sliding_window.lua +17 -0
- limits/storage/__init__.py +7 -6
- limits/storage/base.py +92 -24
- limits/storage/etcd.py +6 -2
- limits/storage/memcached.py +142 -36
- limits/storage/memory.py +97 -12
- limits/storage/mongodb.py +220 -20
- limits/storage/redis.py +159 -138
- limits/storage/redis_cluster.py +3 -3
- limits/storage/redis_sentinel.py +12 -35
- limits/storage/registry.py +3 -3
- limits/strategies.py +119 -5
- limits/typing.py +43 -15
- limits/util.py +27 -18
- limits-4.1.dist-info/METADATA +268 -0
- limits-4.1.dist-info/RECORD +39 -0
- limits-4.0.0.dist-info/METADATA +0 -192
- limits-4.0.0.dist-info/RECORD +0 -37
- {limits-4.0.0.dist-info → limits-4.1.dist-info}/LICENSE.txt +0 -0
- {limits-4.0.0.dist-info → limits-4.1.dist-info}/WHEEL +0 -0
- {limits-4.0.0.dist-info → limits-4.1.dist-info}/top_level.txt +0 -0
limits/storage/redis_cluster.py
CHANGED
|
@@ -4,7 +4,7 @@ from deprecated.sphinx import versionchanged
|
|
|
4
4
|
from packaging.version import Version
|
|
5
5
|
|
|
6
6
|
from limits.storage.redis import RedisStorage
|
|
7
|
-
from limits.typing import
|
|
7
|
+
from limits.typing import Optional, Union
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@versionchanged(
|
|
@@ -34,7 +34,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
34
34
|
STORAGE_SCHEME = ["redis+cluster"]
|
|
35
35
|
"""The storage scheme for redis cluster"""
|
|
36
36
|
|
|
37
|
-
DEFAULT_OPTIONS:
|
|
37
|
+
DEFAULT_OPTIONS: dict[str, Union[float, str, bool]] = {
|
|
38
38
|
"max_connections": 1000,
|
|
39
39
|
}
|
|
40
40
|
"Default options passed to the :class:`~redis.cluster.RedisCluster`"
|
|
@@ -60,7 +60,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
60
60
|
available or if the redis cluster cannot be reached.
|
|
61
61
|
"""
|
|
62
62
|
parsed = urllib.parse.urlparse(uri)
|
|
63
|
-
parsed_auth:
|
|
63
|
+
parsed_auth: dict[str, Union[float, str, bool]] = {}
|
|
64
64
|
|
|
65
65
|
if parsed.username:
|
|
66
66
|
parsed_auth["username"] = parsed.username
|
limits/storage/redis_sentinel.py
CHANGED
|
@@ -5,10 +5,10 @@ from packaging.version import Version
|
|
|
5
5
|
|
|
6
6
|
from limits.errors import ConfigurationError
|
|
7
7
|
from limits.storage.redis import RedisStorage
|
|
8
|
-
from limits.typing import
|
|
8
|
+
from limits.typing import Optional, RedisClient, Type, Union
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
|
-
|
|
11
|
+
pass
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class RedisSentinelStorage(RedisStorage):
|
|
@@ -21,14 +21,14 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
21
21
|
STORAGE_SCHEME = ["redis+sentinel"]
|
|
22
22
|
"""The storage scheme for redis accessed via a redis sentinel installation"""
|
|
23
23
|
|
|
24
|
-
DEPENDENCIES = {"redis.sentinel": Version("3.0")}
|
|
24
|
+
DEPENDENCIES = {"redis": Version("3.0"), "redis.sentinel": Version("3.0")}
|
|
25
25
|
|
|
26
26
|
def __init__(
|
|
27
27
|
self,
|
|
28
28
|
uri: str,
|
|
29
29
|
service_name: Optional[str] = None,
|
|
30
30
|
use_replicas: bool = True,
|
|
31
|
-
sentinel_kwargs: Optional[
|
|
31
|
+
sentinel_kwargs: Optional[dict[str, Union[float, str, bool]]] = None,
|
|
32
32
|
wrap_exceptions: bool = False,
|
|
33
33
|
**options: Union[float, str, bool],
|
|
34
34
|
) -> None:
|
|
@@ -56,7 +56,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
56
56
|
sentinel_configuration = []
|
|
57
57
|
sentinel_options = sentinel_kwargs.copy() if sentinel_kwargs else {}
|
|
58
58
|
|
|
59
|
-
parsed_auth:
|
|
59
|
+
parsed_auth: dict[str, Union[float, str, bool]] = {}
|
|
60
60
|
|
|
61
61
|
if parsed.username:
|
|
62
62
|
parsed_auth["username"] = parsed.username
|
|
@@ -76,44 +76,21 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
76
76
|
raise ConfigurationError("'service_name' not provided")
|
|
77
77
|
|
|
78
78
|
sentinel_dep = self.dependencies["redis.sentinel"].module
|
|
79
|
-
self.sentinel
|
|
79
|
+
self.sentinel = sentinel_dep.Sentinel(
|
|
80
80
|
sentinel_configuration,
|
|
81
81
|
sentinel_kwargs={**parsed_auth, **sentinel_options},
|
|
82
82
|
**{**parsed_auth, **options},
|
|
83
83
|
)
|
|
84
|
-
self.storage = self.sentinel.master_for(self.service_name)
|
|
85
|
-
self.storage_slave = self.sentinel.slave_for(self.service_name)
|
|
84
|
+
self.storage: RedisClient = self.sentinel.master_for(self.service_name)
|
|
85
|
+
self.storage_slave: RedisClient = self.sentinel.slave_for(self.service_name)
|
|
86
86
|
self.use_replicas = use_replicas
|
|
87
87
|
self.initialize_storage(uri)
|
|
88
88
|
|
|
89
89
|
@property
|
|
90
90
|
def base_exceptions(
|
|
91
91
|
self,
|
|
92
|
-
) -> Union[Type[Exception],
|
|
93
|
-
return self.dependencies["redis"].RedisError # type: ignore[no-any-return
|
|
92
|
+
) -> Union[Type[Exception], tuple[Type[Exception], ...]]: # pragma: no cover
|
|
93
|
+
return self.dependencies["redis"].module.RedisError # type: ignore[no-any-return]
|
|
94
94
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
:param key: the key to get the counter value for
|
|
98
|
-
"""
|
|
99
|
-
|
|
100
|
-
return super()._get(
|
|
101
|
-
key, self.storage_slave if self.use_replicas else self.storage
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
def get_expiry(self, key: str) -> float:
|
|
105
|
-
"""
|
|
106
|
-
:param key: the key to get the expiry for
|
|
107
|
-
"""
|
|
108
|
-
|
|
109
|
-
return super()._get_expiry(
|
|
110
|
-
key, self.storage_slave if self.use_replicas else self.storage
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
def check(self) -> bool:
|
|
114
|
-
"""
|
|
115
|
-
Check if storage is healthy by calling :class:`aredis.StrictRedis.ping`
|
|
116
|
-
on the slave.
|
|
117
|
-
"""
|
|
118
|
-
|
|
119
|
-
return super()._check(self.storage_slave if self.use_replicas else self.storage)
|
|
95
|
+
def get_connection(self, readonly: bool = False) -> RedisClient:
|
|
96
|
+
return self.storage_slave if (readonly and self.use_replicas) else self.storage
|
limits/storage/registry.py
CHANGED
|
@@ -2,14 +2,14 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from abc import ABCMeta
|
|
4
4
|
|
|
5
|
-
from limits.typing import
|
|
5
|
+
from limits.typing import Union
|
|
6
6
|
|
|
7
|
-
SCHEMES:
|
|
7
|
+
SCHEMES: dict[str, StorageRegistry] = {}
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class StorageRegistry(ABCMeta):
|
|
11
11
|
def __new__(
|
|
12
|
-
mcs, name: str, bases:
|
|
12
|
+
mcs, name: str, bases: tuple[type, ...], dct: dict[str, Union[str, list[str]]]
|
|
13
13
|
) -> StorageRegistry:
|
|
14
14
|
storage_scheme = dct.get("STORAGE_SCHEME", None)
|
|
15
15
|
cls = super().__new__(mcs, name, bases, dct)
|
limits/strategies.py
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
Rate limiting strategies
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import time
|
|
5
6
|
from abc import ABCMeta, abstractmethod
|
|
6
|
-
from
|
|
7
|
+
from math import floor, inf
|
|
8
|
+
|
|
9
|
+
from deprecated.sphinx import deprecated, versionadded
|
|
10
|
+
|
|
11
|
+
from limits.storage.base import SlidingWindowCounterSupport
|
|
7
12
|
|
|
8
13
|
from .limits import RateLimitItem
|
|
9
14
|
from .storage import MovingWindowSupport, Storage, StorageTypes
|
|
15
|
+
from .typing import Union, cast
|
|
10
16
|
from .util import WindowStats
|
|
11
17
|
|
|
12
18
|
|
|
@@ -173,6 +179,112 @@ class FixedWindowRateLimiter(RateLimiter):
|
|
|
173
179
|
return WindowStats(reset, remaining)
|
|
174
180
|
|
|
175
181
|
|
|
182
|
+
@versionadded(version="4.1")
|
|
183
|
+
class SlidingWindowCounterRateLimiter(RateLimiter):
|
|
184
|
+
"""
|
|
185
|
+
Reference: :ref:`strategies:sliding window counter`
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, storage: StorageTypes):
|
|
189
|
+
if not hasattr(storage, "get_sliding_window") or not hasattr(
|
|
190
|
+
storage, "acquire_sliding_window_entry"
|
|
191
|
+
):
|
|
192
|
+
raise NotImplementedError(
|
|
193
|
+
"SlidingWindowCounterRateLimiting is not implemented for storage "
|
|
194
|
+
"of type %s" % storage.__class__
|
|
195
|
+
)
|
|
196
|
+
super().__init__(storage)
|
|
197
|
+
|
|
198
|
+
def _weighted_count(
|
|
199
|
+
self,
|
|
200
|
+
item: RateLimitItem,
|
|
201
|
+
previous_count: int,
|
|
202
|
+
previous_expires_in: float,
|
|
203
|
+
current_count: int,
|
|
204
|
+
) -> float:
|
|
205
|
+
"""
|
|
206
|
+
Return the approximated by weighting the previous window count and adding the current window count.
|
|
207
|
+
"""
|
|
208
|
+
return previous_count * previous_expires_in / item.get_expiry() + current_count
|
|
209
|
+
|
|
210
|
+
def hit(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
|
211
|
+
"""
|
|
212
|
+
Consume the rate limit
|
|
213
|
+
|
|
214
|
+
:param item: The rate limit item
|
|
215
|
+
:param identifiers: variable list of strings to uniquely identify this
|
|
216
|
+
instance of the limit
|
|
217
|
+
:param cost: The cost of this hit, default 1
|
|
218
|
+
"""
|
|
219
|
+
return cast(
|
|
220
|
+
SlidingWindowCounterSupport, self.storage
|
|
221
|
+
).acquire_sliding_window_entry(
|
|
222
|
+
item.key_for(*identifiers),
|
|
223
|
+
item.amount,
|
|
224
|
+
item.get_expiry(),
|
|
225
|
+
cost,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def test(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
|
229
|
+
"""
|
|
230
|
+
Check if the rate limit can be consumed
|
|
231
|
+
|
|
232
|
+
:param item: The rate limit item
|
|
233
|
+
:param identifiers: variable list of strings to uniquely identify this
|
|
234
|
+
instance of the limit
|
|
235
|
+
:param cost: The expected cost to be consumed, default 1
|
|
236
|
+
"""
|
|
237
|
+
previous_count, previous_expires_in, current_count, _ = cast(
|
|
238
|
+
SlidingWindowCounterSupport, self.storage
|
|
239
|
+
).get_sliding_window(item.key_for(*identifiers), item.get_expiry())
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
self._weighted_count(
|
|
243
|
+
item, previous_count, previous_expires_in, current_count
|
|
244
|
+
)
|
|
245
|
+
< item.amount - cost + 1
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def get_window_stats(self, item: RateLimitItem, *identifiers: str) -> WindowStats:
|
|
249
|
+
"""
|
|
250
|
+
Query the reset time and remaining amount for the limit.
|
|
251
|
+
|
|
252
|
+
:param item: The rate limit item
|
|
253
|
+
:param identifiers: variable list of strings to uniquely identify this
|
|
254
|
+
instance of the limit
|
|
255
|
+
:return: WindowStats(reset time, remaining)
|
|
256
|
+
"""
|
|
257
|
+
previous_count, previous_expires_in, current_count, current_expires_in = cast(
|
|
258
|
+
SlidingWindowCounterSupport, self.storage
|
|
259
|
+
).get_sliding_window(item.key_for(*identifiers), item.get_expiry())
|
|
260
|
+
|
|
261
|
+
remaining = max(
|
|
262
|
+
0,
|
|
263
|
+
item.amount
|
|
264
|
+
- floor(
|
|
265
|
+
self._weighted_count(
|
|
266
|
+
item, previous_count, previous_expires_in, current_count
|
|
267
|
+
)
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
now = time.time()
|
|
272
|
+
|
|
273
|
+
if not (previous_count or current_count):
|
|
274
|
+
return WindowStats(now, remaining)
|
|
275
|
+
|
|
276
|
+
expiry = item.get_expiry()
|
|
277
|
+
|
|
278
|
+
previous_reset_in, current_reset_in = inf, inf
|
|
279
|
+
if previous_count:
|
|
280
|
+
previous_reset_in = previous_expires_in % (expiry / previous_count)
|
|
281
|
+
if current_count:
|
|
282
|
+
current_reset_in = current_expires_in % expiry
|
|
283
|
+
|
|
284
|
+
return WindowStats(now + min(previous_reset_in, current_reset_in), remaining)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@deprecated(version="4.1", action="always")
|
|
176
288
|
class FixedWindowElasticExpiryRateLimiter(FixedWindowRateLimiter):
|
|
177
289
|
"""
|
|
178
290
|
Reference: :ref:`strategies:fixed window with elastic expiry`
|
|
@@ -200,12 +312,14 @@ class FixedWindowElasticExpiryRateLimiter(FixedWindowRateLimiter):
|
|
|
200
312
|
|
|
201
313
|
|
|
202
314
|
KnownStrategy = Union[
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
315
|
+
type[SlidingWindowCounterRateLimiter],
|
|
316
|
+
type[FixedWindowRateLimiter],
|
|
317
|
+
type[FixedWindowElasticExpiryRateLimiter],
|
|
318
|
+
type[MovingWindowRateLimiter],
|
|
206
319
|
]
|
|
207
320
|
|
|
208
|
-
STRATEGIES:
|
|
321
|
+
STRATEGIES: dict[str, KnownStrategy] = {
|
|
322
|
+
"sliding-window-counter": SlidingWindowCounterRateLimiter,
|
|
209
323
|
"fixed-window": FixedWindowRateLimiter,
|
|
210
324
|
"fixed-window-elastic-expiry": FixedWindowElasticExpiryRateLimiter,
|
|
211
325
|
"moving-window": MovingWindowRateLimiter,
|
limits/typing.py
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
from collections.abc import Awaitable, Iterable
|
|
1
3
|
from typing import (
|
|
2
4
|
TYPE_CHECKING,
|
|
3
5
|
Any,
|
|
4
|
-
Awaitable,
|
|
5
6
|
Callable,
|
|
6
|
-
|
|
7
|
-
List,
|
|
7
|
+
ClassVar,
|
|
8
8
|
NamedTuple,
|
|
9
9
|
Optional,
|
|
10
|
-
Tuple,
|
|
11
10
|
Type,
|
|
12
11
|
TypeVar,
|
|
13
12
|
Union,
|
|
13
|
+
cast,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
from typing_extensions import
|
|
16
|
+
from typing_extensions import ParamSpec, Protocol, TypeAlias
|
|
17
17
|
|
|
18
18
|
Serializable = Union[int, str, float]
|
|
19
19
|
|
|
@@ -25,7 +25,8 @@ P = ParamSpec("P")
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
import coredis
|
|
27
27
|
import coredis.commands.script
|
|
28
|
-
import pymongo
|
|
28
|
+
import pymongo.collection
|
|
29
|
+
import pymongo.database
|
|
29
30
|
import redis
|
|
30
31
|
|
|
31
32
|
|
|
@@ -48,12 +49,18 @@ class EmcacheClientP(Protocol):
|
|
|
48
49
|
|
|
49
50
|
async def get(self, key: bytes, return_flags: bool = False) -> Optional[ItemP]: ...
|
|
50
51
|
|
|
52
|
+
async def get_many(self, keys: Iterable[bytes]) -> dict[bytes, ItemP]: ...
|
|
53
|
+
|
|
51
54
|
async def gets(self, key: bytes, return_flags: bool = False) -> Optional[ItemP]: ...
|
|
52
55
|
|
|
53
56
|
async def increment(
|
|
54
57
|
self, key: bytes, value: int, *, noreply: bool = False
|
|
55
58
|
) -> Optional[int]: ...
|
|
56
59
|
|
|
60
|
+
async def decrement(
|
|
61
|
+
self, key: bytes, value: int, *, noreply: bool = False
|
|
62
|
+
) -> Optional[int]: ...
|
|
63
|
+
|
|
57
64
|
async def delete(self, key: bytes, *, noreply: bool = False) -> None: ...
|
|
58
65
|
|
|
59
66
|
async def set(
|
|
@@ -83,7 +90,18 @@ class MemcachedClientP(Protocol):
|
|
|
83
90
|
|
|
84
91
|
def get(self, key: str, default: Optional[str] = None) -> bytes: ...
|
|
85
92
|
|
|
86
|
-
def
|
|
93
|
+
def get_many(self, keys: Iterable[str]) -> dict[str, Any]: ... # type:ignore[explicit-any]
|
|
94
|
+
|
|
95
|
+
def incr(
|
|
96
|
+
self, key: str, value: int, noreply: Optional[bool] = False
|
|
97
|
+
) -> Optional[int]: ...
|
|
98
|
+
|
|
99
|
+
def decr(
|
|
100
|
+
self,
|
|
101
|
+
key: str,
|
|
102
|
+
value: int,
|
|
103
|
+
noreply: Optional[bool] = False,
|
|
104
|
+
) -> Optional[int]: ...
|
|
87
105
|
|
|
88
106
|
def delete(self, key: str, noreply: Optional[bool] = None) -> Optional[bool]: ...
|
|
89
107
|
|
|
@@ -101,28 +119,37 @@ class MemcachedClientP(Protocol):
|
|
|
101
119
|
) -> bool: ...
|
|
102
120
|
|
|
103
121
|
|
|
122
|
+
class RedisClientP(Protocol):
|
|
123
|
+
def incrby(self, key: str, amount: int) -> int: ...
|
|
124
|
+
def get(self, key: str) -> Optional[bytes]: ...
|
|
125
|
+
def delete(self, key: str) -> int: ...
|
|
126
|
+
def ttl(self, key: str) -> int: ...
|
|
127
|
+
def expire(self, key: str, seconds: int) -> bool: ...
|
|
128
|
+
def ping(self) -> bool: ...
|
|
129
|
+
def register_script(self, script: bytes) -> "redis.commands.core.Script": ...
|
|
130
|
+
|
|
131
|
+
|
|
104
132
|
AsyncRedisClient = Union["coredis.Redis[bytes]", "coredis.RedisCluster[bytes]"]
|
|
105
|
-
RedisClient =
|
|
133
|
+
RedisClient = RedisClientP
|
|
106
134
|
|
|
107
135
|
|
|
108
136
|
class ScriptP(Protocol[R_co]):
|
|
109
|
-
def __call__(self, keys:
|
|
137
|
+
def __call__(self, keys: list[Serializable], args: list[Serializable]) -> R_co: ...
|
|
110
138
|
|
|
111
139
|
|
|
112
|
-
MongoClient: TypeAlias = "pymongo.MongoClient[
|
|
113
|
-
MongoDatabase: TypeAlias = "pymongo.database.Database[
|
|
114
|
-
MongoCollection: TypeAlias = "pymongo.collection.Collection[
|
|
140
|
+
MongoClient: TypeAlias = "pymongo.MongoClient[dict[str, Any]]" # type:ignore[explicit-any]
|
|
141
|
+
MongoDatabase: TypeAlias = "pymongo.database.Database[dict[str, Any]]" # type:ignore[explicit-any]
|
|
142
|
+
MongoCollection: TypeAlias = "pymongo.collection.Collection[dict[str, Any]]" # type:ignore[explicit-any]
|
|
115
143
|
|
|
116
144
|
__all__ = [
|
|
145
|
+
"Any",
|
|
117
146
|
"AsyncRedisClient",
|
|
118
147
|
"Awaitable",
|
|
119
148
|
"Callable",
|
|
120
149
|
"ClassVar",
|
|
121
150
|
"Counter",
|
|
122
|
-
"Dict",
|
|
123
151
|
"EmcacheClientP",
|
|
124
152
|
"ItemP",
|
|
125
|
-
"List",
|
|
126
153
|
"MemcachedClientP",
|
|
127
154
|
"MongoClient",
|
|
128
155
|
"MongoCollection",
|
|
@@ -138,8 +165,9 @@ __all__ = [
|
|
|
138
165
|
"R",
|
|
139
166
|
"R_co",
|
|
140
167
|
"RedisClient",
|
|
141
|
-
"Tuple",
|
|
142
168
|
"Type",
|
|
143
169
|
"TypeVar",
|
|
170
|
+
"TYPE_CHECKING",
|
|
144
171
|
"Union",
|
|
172
|
+
"cast",
|
|
145
173
|
]
|
limits/util.py
CHANGED
|
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
|
|
|
10
10
|
|
|
11
11
|
from packaging.version import Version
|
|
12
12
|
|
|
13
|
-
from limits.typing import
|
|
13
|
+
from limits.typing import NamedTuple, Optional, Type, Union
|
|
14
14
|
|
|
15
15
|
from .errors import ConfigurationError
|
|
16
16
|
from .limits import GRANULARITIES, RateLimitItem
|
|
@@ -34,7 +34,7 @@ EXPR = re.compile(
|
|
|
34
34
|
|
|
35
35
|
class WindowStats(NamedTuple):
|
|
36
36
|
"""
|
|
37
|
-
|
|
37
|
+
tuple to describe a rate limited window
|
|
38
38
|
"""
|
|
39
39
|
|
|
40
40
|
#: Time as seconds since the Epoch when this window will be reset
|
|
@@ -51,6 +51,9 @@ class Dependency:
|
|
|
51
51
|
module: ModuleType
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
MissingModule = ModuleType("Missing")
|
|
55
|
+
|
|
56
|
+
|
|
54
57
|
if TYPE_CHECKING:
|
|
55
58
|
_UserDict = UserDict[str, Dependency]
|
|
56
59
|
else:
|
|
@@ -58,20 +61,29 @@ else:
|
|
|
58
61
|
|
|
59
62
|
|
|
60
63
|
class DependencyDict(_UserDict):
|
|
61
|
-
Missing = Dependency("Missing", None, None, ModuleType("Missing"))
|
|
62
|
-
|
|
63
64
|
def __getitem__(self, key: str) -> Dependency:
|
|
64
65
|
dependency = super().__getitem__(key)
|
|
65
66
|
|
|
66
|
-
if dependency
|
|
67
|
-
|
|
67
|
+
if dependency.module is MissingModule:
|
|
68
|
+
message = f"'{dependency.name}' prerequisite not available."
|
|
69
|
+
if dependency.version_required:
|
|
70
|
+
message += (
|
|
71
|
+
f" A minimum version of {dependency.version_required} is required."
|
|
72
|
+
if dependency.version_required
|
|
73
|
+
else ""
|
|
74
|
+
)
|
|
75
|
+
message += (
|
|
76
|
+
" See https://limits.readthedocs.io/en/stable/storage.html#supported-versions"
|
|
77
|
+
" for more details."
|
|
78
|
+
)
|
|
79
|
+
raise ConfigurationError(message)
|
|
68
80
|
elif dependency.version_required and (
|
|
69
81
|
not dependency.version_found
|
|
70
82
|
or dependency.version_found < dependency.version_required
|
|
71
83
|
):
|
|
72
84
|
raise ConfigurationError(
|
|
73
85
|
f"The minimum version of {dependency.version_required}"
|
|
74
|
-
f"
|
|
86
|
+
f" for '{dependency.name}' could not be found. Found version: {dependency.version_found}"
|
|
75
87
|
)
|
|
76
88
|
|
|
77
89
|
return dependency
|
|
@@ -84,7 +96,7 @@ class LazyDependency:
|
|
|
84
96
|
without having to import them explicitly.
|
|
85
97
|
"""
|
|
86
98
|
|
|
87
|
-
DEPENDENCIES: Union[
|
|
99
|
+
DEPENDENCIES: Union[dict[str, Optional[Version]], list[str]] = []
|
|
88
100
|
"""
|
|
89
101
|
The python modules this class has a dependency on.
|
|
90
102
|
Used to lazily populate the :attr:`dependencies`
|
|
@@ -105,7 +117,7 @@ class LazyDependency:
|
|
|
105
117
|
|
|
106
118
|
if not getattr(self, "_dependencies", None):
|
|
107
119
|
dependencies = DependencyDict()
|
|
108
|
-
mapping:
|
|
120
|
+
mapping: dict[str, Optional[Version]]
|
|
109
121
|
|
|
110
122
|
if isinstance(self.DEPENDENCIES, list):
|
|
111
123
|
mapping = {dependency: None for dependency in self.DEPENDENCIES}
|
|
@@ -115,18 +127,15 @@ class LazyDependency:
|
|
|
115
127
|
for name, minimum_version in mapping.items():
|
|
116
128
|
dependency, version = get_dependency(name)
|
|
117
129
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
dependencies[name] = Dependency(
|
|
122
|
-
name, minimum_version, version, dependency
|
|
123
|
-
)
|
|
130
|
+
dependencies[name] = Dependency(
|
|
131
|
+
name, minimum_version, version, dependency
|
|
132
|
+
)
|
|
124
133
|
self._dependencies = dependencies
|
|
125
134
|
|
|
126
135
|
return self._dependencies
|
|
127
136
|
|
|
128
137
|
|
|
129
|
-
def get_dependency(module_path: str) ->
|
|
138
|
+
def get_dependency(module_path: str) -> tuple[ModuleType, Optional[Version]]:
|
|
130
139
|
"""
|
|
131
140
|
safe function to import a module at runtime
|
|
132
141
|
"""
|
|
@@ -138,14 +147,14 @@ def get_dependency(module_path: str) -> Tuple[Optional[ModuleType], Optional[Ver
|
|
|
138
147
|
|
|
139
148
|
return sys.modules[module_path], Version(version)
|
|
140
149
|
except ImportError: # pragma: no cover
|
|
141
|
-
return
|
|
150
|
+
return MissingModule, None
|
|
142
151
|
|
|
143
152
|
|
|
144
153
|
def get_package_data(path: str) -> bytes:
|
|
145
154
|
return importlib.resources.files("limits").joinpath(path).read_bytes()
|
|
146
155
|
|
|
147
156
|
|
|
148
|
-
def parse_many(limit_string: str) ->
|
|
157
|
+
def parse_many(limit_string: str) -> list[RateLimitItem]:
|
|
149
158
|
"""
|
|
150
159
|
parses rate limits in string notation containing multiple rate limits
|
|
151
160
|
(e.g. ``1/second; 5/minute``)
|