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