hikari-arc 0.4.0__py3-none-any.whl → 0.6.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.
Files changed (46) hide show
  1. arc/__init__.py +55 -5
  2. arc/abc/__init__.py +32 -1
  3. arc/abc/client.py +207 -67
  4. arc/abc/command.py +245 -34
  5. arc/abc/error_handler.py +33 -2
  6. arc/abc/hookable.py +24 -0
  7. arc/abc/limiter.py +61 -0
  8. arc/abc/option.py +73 -5
  9. arc/abc/plugin.py +185 -29
  10. arc/client.py +103 -33
  11. arc/command/message.py +21 -18
  12. arc/command/option/attachment.py +9 -5
  13. arc/command/option/bool.py +9 -6
  14. arc/command/option/channel.py +9 -5
  15. arc/command/option/float.py +11 -7
  16. arc/command/option/int.py +11 -7
  17. arc/command/option/mentionable.py +9 -5
  18. arc/command/option/role.py +9 -5
  19. arc/command/option/str.py +11 -7
  20. arc/command/option/user.py +9 -5
  21. arc/command/slash.py +222 -197
  22. arc/command/user.py +20 -17
  23. arc/context/autocomplete.py +1 -0
  24. arc/context/base.py +216 -105
  25. arc/errors.py +52 -10
  26. arc/events.py +5 -1
  27. arc/extension.py +23 -0
  28. arc/internal/about.py +1 -1
  29. arc/internal/deprecation.py +3 -4
  30. arc/internal/options.py +106 -0
  31. arc/internal/sigparse.py +19 -1
  32. arc/internal/sync.py +13 -10
  33. arc/internal/types.py +34 -15
  34. arc/locale.py +28 -0
  35. arc/plugin.py +56 -5
  36. arc/utils/__init__.py +53 -2
  37. arc/utils/hooks/__init__.py +25 -0
  38. arc/utils/{hooks.py → hooks/basic.py} +28 -1
  39. arc/utils/hooks/limiters.py +217 -0
  40. arc/utils/ratelimiter.py +243 -0
  41. {hikari_arc-0.4.0.dist-info → hikari_arc-0.6.0.dist-info}/METADATA +13 -8
  42. hikari_arc-0.6.0.dist-info/RECORD +52 -0
  43. hikari_arc-0.4.0.dist-info/RECORD +0 -47
  44. {hikari_arc-0.4.0.dist-info → hikari_arc-0.6.0.dist-info}/LICENSE +0 -0
  45. {hikari_arc-0.4.0.dist-info → hikari_arc-0.6.0.dist-info}/WHEEL +0 -0
  46. {hikari_arc-0.4.0.dist-info → hikari_arc-0.6.0.dist-info}/top_level.txt +0 -0
arc/abc/command.py CHANGED
@@ -10,8 +10,10 @@ import hikari
10
10
 
11
11
  from arc.abc.error_handler import HasErrorHandler
12
12
  from arc.abc.hookable import Hookable, HookResult
13
+ from arc.abc.limiter import LimiterProto
13
14
  from arc.abc.option import OptionBase
14
15
  from arc.context import AutodeferMode
16
+ from arc.errors import CommandPublishFailedError
15
17
  from arc.internal.types import (
16
18
  BuilderT,
17
19
  ClientT,
@@ -24,8 +26,10 @@ from arc.internal.types import (
24
26
  from arc.locale import CommandLocaleRequest
25
27
 
26
28
  if t.TYPE_CHECKING:
29
+ import typing_extensions as te
30
+
27
31
  from arc.abc.plugin import PluginBase
28
- from arc.context import Context
32
+ from arc.context.base import Context
29
33
 
30
34
 
31
35
  class CommandProto(t.Protocol):
@@ -46,15 +50,29 @@ class CommandProto(t.Protocol):
46
50
  def qualified_name(self) -> t.Sequence[str]:
47
51
  """The fully qualified name of this command."""
48
52
 
53
+ @property
54
+ @abc.abstractmethod
55
+ def display_name(self) -> str:
56
+ """The display name of this command. This is what is shown in the Discord client.
57
+
58
+ !!! note
59
+ Slash commands can also be mentioned, see [SlashCommand.make_mention][arc.command.SlashCommand.make_mention].
60
+ """
61
+
62
+
63
+ class CallableCommandProto(CommandProto, t.Protocol[ClientT]):
64
+ """A protocol for any command-like object that can be called directly.
49
65
 
50
- class CallableCommandProto(t.Protocol[ClientT]):
51
- """A protocol for any command-like object that can be called directly. This includes commands and subcommands."""
66
+ This includes commands and subcommands, but not groups or subgroups.
67
+ """
52
68
 
53
69
  name: str
54
70
  """The name of the command."""
55
71
  name_localizations: t.Mapping[hikari.Locale, str]
56
72
  """The name of the command in different locales."""
73
+
57
74
  callback: CommandCallbackT[ClientT]
75
+ """The callback to invoke when this command is called."""
58
76
 
59
77
  @property
60
78
  @abc.abstractmethod
@@ -66,6 +84,35 @@ class CallableCommandProto(t.Protocol[ClientT]):
66
84
  def qualified_name(self) -> t.Sequence[str]:
67
85
  """The fully qualified name of this command."""
68
86
 
87
+ @property
88
+ @abc.abstractmethod
89
+ def display_name(self) -> str:
90
+ """The display name of this command. This is what is shown in the Discord client.
91
+
92
+ !!! note
93
+ Slash commands can also be mentioned, see [SlashCommand.make_mention][arc.command.SlashCommand.make_mention].
94
+ """
95
+
96
+ @property
97
+ @abc.abstractmethod
98
+ def hooks(self) -> t.MutableSequence[HookT[ClientT]]:
99
+ """The pre-execution hooks for this command."""
100
+
101
+ @property
102
+ @abc.abstractmethod
103
+ def post_hooks(self) -> t.MutableSequence[PostHookT[ClientT]]:
104
+ """The post-execution hooks for this command."""
105
+
106
+ @abc.abstractmethod
107
+ def reset_all_limiters(self, context: Context[ClientT]) -> None:
108
+ """Reset all limiters for this command.
109
+
110
+ Parameters
111
+ ----------
112
+ context : Context
113
+ The context to reset the limiters for.
114
+ """
115
+
69
116
  @abc.abstractmethod
70
117
  async def __call__(self, ctx: Context[ClientT], *args: t.Any, **kwargs: t.Any) -> None:
71
118
  """Call the callback of the command with the given context and arguments.
@@ -110,55 +157,92 @@ class CallableCommandProto(t.Protocol[ClientT]):
110
157
  async def _handle_exception(self, ctx: Context[ClientT], exc: Exception) -> None:
111
158
  ...
112
159
 
160
+ @abc.abstractmethod
113
161
  def _resolve_hooks(self) -> t.Sequence[HookT[ClientT]]:
114
162
  """Resolve all pre-execution hooks that apply to this object."""
115
163
  ...
116
164
 
165
+ @abc.abstractmethod
117
166
  def _resolve_post_hooks(self) -> t.Sequence[PostHookT[ClientT]]:
118
167
  """Resolve all post-execution hooks that apply to this object."""
119
168
  ...
120
169
 
121
170
 
171
+ @t.final
172
+ @attr.define(slots=True, kw_only=True, weakref_slot=False)
173
+ class _CommandSettings:
174
+ """All the command settings that need to propagate and be inherited."""
175
+
176
+ autodefer: AutodeferMode | hikari.UndefinedType
177
+ default_permissions: hikari.Permissions | hikari.UndefinedType
178
+ is_nsfw: bool | hikari.UndefinedType
179
+ is_dm_enabled: bool | hikari.UndefinedType
180
+
181
+ def apply(self, other: te.Self) -> te.Self:
182
+ """Apply 'other' to this, copying all the non-undefined settings to it."""
183
+ return type(self)(
184
+ autodefer=other.autodefer if other.autodefer is not hikari.UNDEFINED else self.autodefer,
185
+ default_permissions=other.default_permissions
186
+ if other.default_permissions is not hikari.UNDEFINED
187
+ else self.default_permissions,
188
+ is_nsfw=other.is_nsfw if other.is_nsfw is not hikari.UNDEFINED else self.is_nsfw,
189
+ is_dm_enabled=other.is_dm_enabled if other.is_dm_enabled is not hikari.UNDEFINED else self.is_dm_enabled,
190
+ )
191
+
192
+ @classmethod
193
+ def default(cls) -> te.Self:
194
+ """Get the default command settings."""
195
+ return cls(autodefer=AutodeferMode.ON, default_permissions=hikari.UNDEFINED, is_nsfw=False, is_dm_enabled=True)
196
+
197
+
122
198
  @attr.define(slots=True, kw_only=True)
123
199
  class CommandBase(HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT, BuilderT]):
124
- """An abstract base class for all application commands."""
200
+ """An abstract base class for all application commands.
201
+
202
+ This notably does not include subcommands & subgroups as those are in reality options.
203
+ """
125
204
 
126
205
  name: str
127
206
  """The name of this command."""
128
207
 
129
- _client: ClientT | None = attr.field(init=False, default=None)
130
- """The client that is handling this command."""
131
-
132
- _plugin: PluginBase[ClientT] | None = attr.field(init=False, default=None)
133
- """The plugin that this command belongs to, if any."""
134
-
135
- guilds: hikari.UndefinedOr[t.Sequence[hikari.Snowflake]] = hikari.UNDEFINED
208
+ guilds: t.Sequence[hikari.Snowflake] | hikari.UndefinedType = hikari.UNDEFINED
136
209
  """The guilds this command is available in."""
137
210
 
138
- autodefer: AutodeferMode = AutodeferMode.ON
211
+ _autodefer: AutodeferMode | hikari.UndefinedType = attr.field(default=hikari.UNDEFINED, alias="autodefer")
139
212
  """If ON, this command will be automatically deferred if it takes longer than 2 seconds to respond."""
140
213
 
141
- is_dm_enabled: bool = True
214
+ _is_dm_enabled: bool | hikari.UndefinedType = attr.field(default=hikari.UNDEFINED, alias="is_dm_enabled")
142
215
  """Whether this command is enabled in DMs."""
143
216
 
144
- default_permissions: hikari.UndefinedOr[hikari.Permissions] = hikari.UNDEFINED
217
+ _default_permissions: hikari.Permissions | hikari.UndefinedType = attr.field(
218
+ default=hikari.UNDEFINED, alias="default_permissions"
219
+ )
145
220
  """The default permissions for this command.
146
221
  Keep in mind that guild administrators can change this, it should only be used to provide safe defaults."""
147
222
 
223
+ _is_nsfw: bool | hikari.UndefinedType = attr.field(default=hikari.UNDEFINED, alias="is_nsfw")
224
+ """Whether this command is NSFW. If true, the command will only be available in NSFW channels."""
225
+
148
226
  name_localizations: t.Mapping[hikari.Locale, str] = attr.field(factory=dict)
149
227
  """The localizations for this command's name."""
150
228
 
151
- is_nsfw: bool = False
152
- """Whether this command is NSFW. If true, the command will only be available in NSFW channels."""
153
-
154
229
  _instances: dict[hikari.Snowflake | None, hikari.PartialCommand] = attr.field(factory=dict)
155
230
  """A mapping of guild IDs to command instances. None corresponds to the global instance, if any."""
156
231
 
232
+ _client: ClientT | None = attr.field(init=False, default=None)
233
+ """The client that is handling this command."""
234
+
235
+ _plugin: PluginBase[ClientT] | None = attr.field(init=False, default=None)
236
+ """The plugin that this command belongs to, if any."""
237
+
157
238
  _error_handler: ErrorHandlerCallbackT[ClientT] | None = attr.field(init=False, default=None)
239
+ """The error handler for this command."""
158
240
 
159
241
  _hooks: list[HookT[ClientT]] = attr.field(init=False, factory=list)
242
+ """The pre-execution hooks for this command."""
160
243
 
161
244
  _post_hooks: list[PostHookT[ClientT]] = attr.field(init=False, factory=list)
245
+ """The post-execution hooks for this command."""
162
246
 
163
247
  @property
164
248
  def error_handler(self) -> ErrorHandlerCallbackT[ClientT] | None:
@@ -195,7 +279,7 @@ class CommandBase(HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT
195
279
  """The client that is handling this command."""
196
280
  if self._client is None:
197
281
  raise RuntimeError(
198
- f"Command '{self.qualified_name}' was not included in a client, '{type(self).__name__}.client' cannot be accessed until it is included in a client."
282
+ f"Command '{self.display_name}' was not included in a client, '{type(self).__name__}.client' cannot be accessed until it is included in a client."
199
283
  )
200
284
  return self._client
201
285
 
@@ -204,6 +288,43 @@ class CommandBase(HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT
204
288
  """The plugin that this command belongs to, if any."""
205
289
  return self._plugin
206
290
 
291
+ @property
292
+ def autodefer(self) -> AutodeferMode:
293
+ """The resolved autodefer configuration for this command."""
294
+ settings = self._resolve_settings()
295
+ return settings.autodefer if settings.autodefer is not hikari.UNDEFINED else AutodeferMode.ON
296
+
297
+ @property
298
+ def is_dm_enabled(self) -> bool:
299
+ """Whether this command is enabled in DMs."""
300
+ settings = self._resolve_settings()
301
+ return settings.is_dm_enabled if settings.is_dm_enabled is not hikari.UNDEFINED else True
302
+
303
+ @property
304
+ def default_permissions(self) -> hikari.Permissions | hikari.UndefinedType:
305
+ """The resolved default permissions for this command."""
306
+ return self._resolve_settings().default_permissions
307
+
308
+ @property
309
+ def is_nsfw(self) -> bool:
310
+ """Whether this command is NSFW. If true, the command will only be available in NSFW channels."""
311
+ settings = self._resolve_settings()
312
+ return settings.is_nsfw if settings.is_nsfw is not hikari.UNDEFINED else False
313
+
314
+ @property
315
+ def instances(self) -> t.Mapping[hikari.Snowflake | None, hikari.PartialCommand]:
316
+ """A mapping of guild IDs to command instances. None corresponds to the global instance, if any."""
317
+ return self._instances
318
+
319
+ @property
320
+ def display_name(self) -> str:
321
+ """The display name of this command. This is what is shown in the Discord client.
322
+
323
+ !!! note
324
+ Slash commands can also be mentioned, see [SlashCommand.make_mention][arc.command.SlashCommand.make_mention].
325
+ """
326
+ return self.name
327
+
207
328
  def _register_instance(
208
329
  self, instance: hikari.PartialCommand, guild: hikari.SnowflakeishOr[hikari.PartialGuild] | None = None
209
330
  ) -> None:
@@ -221,6 +342,24 @@ class CommandBase(HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT
221
342
  else:
222
343
  await self.client._on_error(ctx, exc)
223
344
 
345
+ def _resolve_settings(self) -> _CommandSettings:
346
+ """Resolve all settings that apply to this command."""
347
+ if self._plugin:
348
+ settings = self._plugin._resolve_settings()
349
+ elif self._client:
350
+ settings = self._client._cmd_settings
351
+ else:
352
+ settings = _CommandSettings.default()
353
+
354
+ return settings.apply(
355
+ _CommandSettings(
356
+ autodefer=self._autodefer,
357
+ default_permissions=self._default_permissions,
358
+ is_nsfw=self._is_nsfw,
359
+ is_dm_enabled=self._is_dm_enabled,
360
+ )
361
+ )
362
+
224
363
  def _resolve_hooks(self) -> list[HookT[ClientT]]:
225
364
  plugin_hooks = self.plugin._resolve_hooks() if self.plugin else []
226
365
  return self.client._hooks + plugin_hooks + self._hooks
@@ -246,13 +385,15 @@ class CommandBase(HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT
246
385
  raise RuntimeError("Cannot publish command without a client.")
247
386
 
248
387
  kwargs = self._to_dict()
249
-
250
- if self.command_type is hikari.CommandType.SLASH:
251
- created = await self.client.app.rest.create_slash_command(self.client.application, **kwargs)
252
- else:
253
- created = await self.client.app.rest.create_context_menu_command(
254
- self.client.application, type=self.command_type, **kwargs
255
- )
388
+ try:
389
+ if self.command_type is hikari.CommandType.SLASH:
390
+ created = await self.client.app.rest.create_slash_command(self.client.application, **kwargs)
391
+ else:
392
+ created = await self.client.app.rest.create_context_menu_command(
393
+ self.client.application, type=self.command_type, **kwargs
394
+ )
395
+ except Exception as e:
396
+ raise CommandPublishFailedError(self, f"Failed to publish command '{self.display_name}'") from e
256
397
 
257
398
  self._instances[hikari.Snowflake(guild) if guild else None] = created
258
399
 
@@ -321,8 +462,8 @@ class CommandBase(HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT
321
462
 
322
463
  def _client_remove_hook(self, client: ClientT) -> None:
323
464
  """Called when the client requests the command be removed from it."""
324
- self._client = None
325
465
  self.client._remove_command(self)
466
+ self._client = None
326
467
 
327
468
  def _plugin_include_hook(self, plugin: PluginBase[ClientT]) -> None:
328
469
  """Called when the plugin requests the command be added to it."""
@@ -360,10 +501,10 @@ class CommandBase(HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT
360
501
  try:
361
502
  hooks = command._resolve_hooks()
362
503
  for hook in hooks:
363
- if inspect.iscoroutinefunction(hook):
364
- res = await hook(ctx)
365
- else:
366
- res = hook(ctx)
504
+ res = hook(ctx)
505
+
506
+ if inspect.isawaitable(res):
507
+ res = await res
367
508
 
368
509
  res = t.cast(HookResult | None, res)
369
510
 
@@ -405,13 +546,27 @@ class CommandBase(HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT
405
546
 
406
547
 
407
548
  @attr.define(slots=True, kw_only=True)
408
- class CallableCommandBase(CommandBase[ClientT, BuilderT]):
409
- """A command that can be called directly. Note that this does not include subcommands, as those are options."""
549
+ class CallableCommandBase(CommandBase[ClientT, BuilderT], CallableCommandProto[ClientT]):
550
+ """A top-level command that can be called directly. Note that this does not include subcommands, as those are options."""
410
551
 
411
552
  callback: CommandCallbackT[ClientT]
412
553
  """The callback to invoke when this command is called."""
413
554
 
414
- _invoke_task: asyncio.Task[t.Any] | None = attr.field(init=False, default=None)
555
+ _invoke_task: asyncio.Task[t.Any] | None = attr.field(init=False, default=None, repr=False)
556
+
557
+ def reset_all_limiters(self, context: Context[ClientT]) -> None:
558
+ """Reset all limiters for this command.
559
+
560
+ Parameters
561
+ ----------
562
+ context : Context
563
+ The context to reset the limiters for.
564
+ """
565
+ limiters: t.Generator[LimiterProto[ClientT], None, None] = (
566
+ lim for lim in self._resolve_hooks() if isinstance(lim, LimiterProto)
567
+ )
568
+ for limiter in limiters:
569
+ limiter.reset(context)
415
570
 
416
571
  async def __call__(self, ctx: Context[ClientT], *args: t.Any, **kwargs: t.Any) -> None:
417
572
  await self.callback(ctx, *args, **kwargs)
@@ -430,6 +585,7 @@ class CallableCommandBase(CommandBase[ClientT, BuilderT]):
430
585
  ParentT = t.TypeVar("ParentT")
431
586
 
432
587
 
588
+ @attr.define(slots=True, kw_only=True)
433
589
  class SubCommandBase(OptionBase[ClientT], HasErrorHandler[ClientT], Hookable[ClientT], t.Generic[ClientT, ParentT]):
434
590
  """An abstract base class for all slash subcommands and subgroups."""
435
591
 
@@ -439,9 +595,18 @@ class SubCommandBase(OptionBase[ClientT], HasErrorHandler[ClientT], Hookable[Cli
439
595
 
440
596
  _post_hooks: list[PostHookT[ClientT]] = attr.field(factory=list, init=False)
441
597
 
442
- parent: ParentT | None = attr.field(default=None, init=False)
598
+ _parent: ParentT | None = attr.field(default=None, init=False, alias="parent")
443
599
  """The parent of this subcommand or subgroup."""
444
600
 
601
+ @property
602
+ @abc.abstractmethod
603
+ def display_name(self) -> str:
604
+ """The display name of this command. This is what is shown in the Discord client.
605
+
606
+ !!! note
607
+ Slash commands can also be mentioned, see [SlashCommand.make_mention][arc.command.SlashCommand.make_mention].
608
+ """
609
+
445
610
  @property
446
611
  def error_handler(self) -> ErrorHandlerCallbackT[ClientT] | None:
447
612
  """The error handler for this object."""
@@ -461,3 +626,49 @@ class SubCommandBase(OptionBase[ClientT], HasErrorHandler[ClientT], Hookable[Cli
461
626
  def post_hooks(self) -> t.MutableSequence[PostHookT[ClientT]]:
462
627
  """The post-execution hooks for this object."""
463
628
  return self._post_hooks
629
+
630
+ @property
631
+ def parent(self) -> ParentT:
632
+ """The parent of this subcommand or subgroup."""
633
+ if self._parent is None:
634
+ raise RuntimeError(
635
+ f"Subcommand '{self.name}' was not included in a parent, '{type(self).__name__}.parent' cannot be accessed until it is included in a parent."
636
+ )
637
+ return self._parent
638
+
639
+ def reset_all_limiters(self, context: Context[ClientT]) -> None:
640
+ """Reset all limiters for this command.
641
+
642
+ Parameters
643
+ ----------
644
+ context : Context
645
+ The context to reset the limiters for.
646
+ """
647
+ limiters: t.Generator[LimiterProto[ClientT], None, None] = (
648
+ lim for lim in self._resolve_hooks() if isinstance(lim, LimiterProto)
649
+ )
650
+ for limiter in limiters:
651
+ limiter.reset(context)
652
+
653
+
654
+ # MIT License
655
+ #
656
+ # Copyright (c) 2023-present hypergonial
657
+ #
658
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
659
+ # of this software and associated documentation files (the "Software"), to deal
660
+ # in the Software without restriction, including without limitation the rights
661
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
662
+ # copies of the Software, and to permit persons to whom the Software is
663
+ # furnished to do so, subject to the following conditions:
664
+ #
665
+ # The above copyright notice and this permission notice shall be included in all
666
+ # copies or substantial portions of the Software.
667
+ #
668
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
669
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
670
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
671
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
672
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
673
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
674
+ # SOFTWARE.
arc/abc/error_handler.py CHANGED
@@ -45,12 +45,20 @@ class HasErrorHandler(abc.ABC, t.Generic[ClientT]):
45
45
  @client.include
46
46
  @arc.slash_command("foo", "Foo command description")
47
47
  async def foo(ctx: arc.GatewayContext) -> None:
48
- raise Exception("foo")
48
+ raise RuntimeError("foo")
49
49
 
50
50
  @foo.set_error_handler
51
51
  async def foo_error_handler(ctx: arc.GatewayContext, exc: Exception) -> None:
52
- await ctx.respond("foo failed")
52
+ if isinstance(exc, RuntimeError):
53
+ await ctx.respond("foo failed")
54
+ return
55
+
56
+ raise exc
53
57
  ```
58
+
59
+ !!! warning
60
+ Errors that cannot be handled by the error handler should be re-raised.
61
+ Otherwise they will not propagate to the next error handler.
54
62
  """
55
63
 
56
64
  def decorator(func: ErrorHandlerCallbackT[ClientT]) -> ErrorHandlerCallbackT[ClientT]:
@@ -65,3 +73,26 @@ class HasErrorHandler(abc.ABC, t.Generic[ClientT]):
65
73
  @abc.abstractmethod
66
74
  async def _handle_exception(self, ctx: Context[ClientT], exc: Exception) -> None:
67
75
  """Handle an exception or propagate it to the next error handler if it cannot be handled."""
76
+
77
+
78
+ # MIT License
79
+ #
80
+ # Copyright (c) 2023-present hypergonial
81
+ #
82
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
83
+ # of this software and associated documentation files (the "Software"), to deal
84
+ # in the Software without restriction, including without limitation the rights
85
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
86
+ # copies of the Software, and to permit persons to whom the Software is
87
+ # furnished to do so, subject to the following conditions:
88
+ #
89
+ # The above copyright notice and this permission notice shall be included in all
90
+ # copies or substantial portions of the Software.
91
+ #
92
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
93
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
94
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
95
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
96
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
97
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
98
+ # SOFTWARE.
arc/abc/hookable.py CHANGED
@@ -9,6 +9,7 @@ if t.TYPE_CHECKING:
9
9
  import typing_extensions as te
10
10
 
11
11
 
12
+ @t.final
12
13
  class HookResult:
13
14
  """The result of a hook.
14
15
 
@@ -138,3 +139,26 @@ def with_post_hook(hook: PostHookT[ClientT]) -> t.Callable[[HookableT], Hookable
138
139
  return hookable
139
140
 
140
141
  return decorator
142
+
143
+
144
+ # MIT License
145
+ #
146
+ # Copyright (c) 2023-present hypergonial
147
+ #
148
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
149
+ # of this software and associated documentation files (the "Software"), to deal
150
+ # in the Software without restriction, including without limitation the rights
151
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
152
+ # copies of the Software, and to permit persons to whom the Software is
153
+ # furnished to do so, subject to the following conditions:
154
+ #
155
+ # The above copyright notice and this permission notice shall be included in all
156
+ # copies or substantial portions of the Software.
157
+ #
158
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
159
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
160
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
161
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
162
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
163
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
164
+ # SOFTWARE.
arc/abc/limiter.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import typing as t
5
+
6
+ from arc.internal.types import ClientT
7
+
8
+ if t.TYPE_CHECKING:
9
+ from arc.abc.hookable import HookResult
10
+ from arc.context.base import Context
11
+
12
+
13
+ @t.runtime_checkable
14
+ class LimiterProto(t.Protocol, t.Generic[ClientT]):
15
+ """A protocol that all limiter hooks should implement.
16
+ A limiter is simply a special type of hook with added methods.
17
+
18
+ If you're looking to integrate your own ratelimiter implementation,
19
+ you should make sure to implement all methods defined here.
20
+
21
+ !!! tip
22
+ An easy (but not necessary) way to ensure you've implemented all methods
23
+ is to inherit from this protocol.
24
+ """
25
+
26
+ @abc.abstractmethod
27
+ async def __call__(self, ctx: Context[ClientT]) -> HookResult:
28
+ """Call the limiter with the given context.
29
+ Implementations should raise an exception if the limiter is ratelimited
30
+ or abort via [`HookResult`][arc.abc.hookable.HookResult].
31
+
32
+ Parameters
33
+ ----------
34
+ ctx : Context
35
+ The context to evaluate the ratelimit under.
36
+ """
37
+
38
+ @abc.abstractmethod
39
+ def reset(self, ctx: Context[ClientT]) -> None:
40
+ """Reset the limiter for the given context.
41
+
42
+ Parameters
43
+ ----------
44
+ ctx : Context
45
+ The context to reset the ratelimit for.
46
+ """
47
+
48
+ @abc.abstractmethod
49
+ def is_rate_limited(self, ctx: Context[ClientT]) -> bool:
50
+ """Check if the limiter is rate limited for the given context.
51
+
52
+ Parameters
53
+ ----------
54
+ ctx : Context
55
+ The context to evaluate the ratelimit under.
56
+
57
+ Returns
58
+ -------
59
+ bool
60
+ Whether or not the limiter is ratelimited.
61
+ """