limits 4.0.1__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.
@@ -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 Dict, Optional, Tuple, Type, Union
8
+ from limits.typing import Optional, RedisClient, Type, Union
9
9
 
10
10
  if TYPE_CHECKING:
11
- import redis.sentinel
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[Dict[str, Union[float, str, bool]]] = None,
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: Dict[str, Union[float, str, bool]] = {}
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: "redis.sentinel.Sentinel" = sentinel_dep.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], Tuple[Type[Exception], ...]]: # pragma: no cover
93
- return self.dependencies["redis"].RedisError # type: ignore[no-any-return, attr-defined]
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 get(self, key: str) -> int:
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
@@ -2,14 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  from abc import ABCMeta
4
4
 
5
- from limits.typing import Dict, List, Tuple, Union
5
+ from limits.typing import Union
6
6
 
7
- SCHEMES: Dict[str, StorageRegistry] = {}
7
+ SCHEMES: dict[str, StorageRegistry] = {}
8
8
 
9
9
 
10
10
  class StorageRegistry(ABCMeta):
11
11
  def __new__(
12
- mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Union[str, List[str]]]
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 typing import Dict, Type, Union, cast
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
- Type[FixedWindowRateLimiter],
204
- Type[FixedWindowElasticExpiryRateLimiter],
205
- Type[MovingWindowRateLimiter],
315
+ type[SlidingWindowCounterRateLimiter],
316
+ type[FixedWindowRateLimiter],
317
+ type[FixedWindowElasticExpiryRateLimiter],
318
+ type[MovingWindowRateLimiter],
206
319
  ]
207
320
 
208
- STRATEGIES: Dict[str, KnownStrategy] = {
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
- Dict,
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 ClassVar, Counter, ParamSpec, Protocol, TypeAlias
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 incr(self, key: str, value: int, noreply: Optional[bool] = False) -> int: ...
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 = Union["redis.Redis[bytes]", "redis.cluster.RedisCluster[bytes]"]
133
+ RedisClient = RedisClientP
106
134
 
107
135
 
108
136
  class ScriptP(Protocol[R_co]):
109
- def __call__(self, keys: List[Serializable], args: List[Serializable]) -> R_co: ...
137
+ def __call__(self, keys: list[Serializable], args: list[Serializable]) -> R_co: ...
110
138
 
111
139
 
112
- MongoClient: TypeAlias = "pymongo.MongoClient[Dict[str, Any]]" # type:ignore[misc]
113
- MongoDatabase: TypeAlias = "pymongo.database.Database[Dict[str, Any]]" # type:ignore
114
- MongoCollection: TypeAlias = "pymongo.collection.Collection[Dict[str, Any]]" # type:ignore
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 Dict, List, NamedTuple, Optional, Tuple, Type, Union
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
- Tuple to describe a rate limited window
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 == DependencyDict.Missing:
67
- raise ConfigurationError(f"{key} prerequisite not available")
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" of {dependency.name} could not be found"
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[Dict[str, Optional[Version]], List[str]] = []
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: Dict[str, Optional[Version]]
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
- if not dependency:
119
- dependencies[name] = DependencyDict.Missing
120
- else:
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) -> Tuple[Optional[ModuleType], Optional[Version]]:
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 None, None
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) -> List[RateLimitItem]:
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``)