matrix-python 1.1.0a0__tar.gz → 1.3.0a0__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.
Files changed (61) hide show
  1. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/PKG-INFO +1 -1
  2. matrix_python-1.3.0a0/examples/extension.py +45 -0
  3. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/__init__.py +4 -1
  4. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/_version.py +3 -3
  5. matrix_python-1.3.0a0/matrix/bot.py +288 -0
  6. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/command.py +0 -1
  7. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/context.py +0 -1
  8. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/errors.py +13 -6
  9. matrix_python-1.3.0a0/matrix/extension.py +56 -0
  10. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/group.py +46 -0
  11. matrix_python-1.3.0a0/matrix/registry.py +333 -0
  12. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/scheduler.py +6 -1
  13. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix_python.egg-info/PKG-INFO +1 -1
  14. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix_python.egg-info/SOURCES.txt +5 -0
  15. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/pyproject.toml +7 -1
  16. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/test_bot.py +321 -3
  17. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/test_command.py +1 -26
  18. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/test_context.py +0 -1
  19. matrix_python-1.3.0a0/tests/test_extension.py +159 -0
  20. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/test_group.py +34 -2
  21. matrix_python-1.3.0a0/tests/test_registry.py +362 -0
  22. matrix_python-1.1.0a0/matrix/bot.py +0 -495
  23. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/.github/workflows/codeql.yml +0 -0
  24. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/.github/workflows/publish.yml +0 -0
  25. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/.github/workflows/scorecard.yml +0 -0
  26. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/.github/workflows/tests.yml +0 -0
  27. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/.gitignore +0 -0
  28. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/CODE_OF_CONDUCT.md +0 -0
  29. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/CONTRIBUTING.md +0 -0
  30. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/LICENSE +0 -0
  31. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/README.md +0 -0
  32. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/examples/README.md +0 -0
  33. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/examples/checks.py +0 -0
  34. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/examples/config.yaml +0 -0
  35. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/examples/cooldown.py +0 -0
  36. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/examples/error_handling.py +0 -0
  37. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/examples/ping.py +0 -0
  38. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/examples/reaction.py +0 -0
  39. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/examples/scheduler.py +0 -0
  40. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/checks.py +0 -0
  41. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/config.py +0 -0
  42. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/content.py +0 -0
  43. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/help/__init__.py +0 -0
  44. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/help/help_command.py +0 -0
  45. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/help/pagination.py +0 -0
  46. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/message.py +0 -0
  47. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/room.py +0 -0
  48. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix/types.py +0 -0
  49. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix_python.egg-info/dependency_links.txt +0 -0
  50. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix_python.egg-info/requires.txt +0 -0
  51. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/matrix_python.egg-info/top_level.txt +0 -0
  52. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/mypy.ini +0 -0
  53. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/setup.cfg +0 -0
  54. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/config_fixture.yaml +0 -0
  55. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/config_fixture_token.yaml +0 -0
  56. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/help/test_default_help_command.py +0 -0
  57. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/help/test_help_command.py +0 -0
  58. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/help/test_pagination.py +0 -0
  59. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/test_config.py +0 -0
  60. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/test_message.py +0 -0
  61. {matrix_python-1.1.0a0 → matrix_python-1.3.0a0}/tests/test_room.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrix-python
3
- Version: 1.1.0a0
3
+ Version: 1.3.0a0
4
4
  Summary: An easy-to-use Matrix bot framework designed for quick development and minimal setup
5
5
  Author: Simon Roy, Chris Dedman Rollet
6
6
  Maintainer-email: Code Society Lab <admin@codesociety.xyz>
@@ -0,0 +1,45 @@
1
+ from matrix import Extension, Context
2
+
3
+ extension = Extension("math")
4
+
5
+
6
+ @extension.group("math", description="Math Group")
7
+ async def math_group(ctx: Context):
8
+ pass
9
+
10
+
11
+ @math_group.command()
12
+ async def add(ctx: Context, a: int, b: int):
13
+ await ctx.reply(f"**{a} + {b} = {a + b}**")
14
+
15
+
16
+ @math_group.command()
17
+ async def subtract(ctx: Context, a: int, b: int):
18
+ await ctx.reply(f"{a} - {b} = {a - b}")
19
+
20
+
21
+ @math_group.command()
22
+ async def multiply(ctx: Context, a: int, b: int):
23
+ await ctx.reply(f"{a} x {b} = {a * b}")
24
+
25
+
26
+ @math_group.command()
27
+ async def divide(ctx: Context, a: int, b: int):
28
+ await ctx.reply(f"{a} ÷ {b} = {a / b}")
29
+
30
+
31
+ @divide.error(ZeroDivisionError)
32
+ async def divide_error(ctx: Context, error):
33
+ await ctx.reply(f"Divide error: {error}")
34
+
35
+
36
+ """
37
+ from matrix import Bot
38
+ from math_extension import extension as math_extension
39
+
40
+ bot = Bot(config="config.yaml")
41
+
42
+
43
+ bot.load_extension(math_extension)
44
+ bot.start()
45
+ """
@@ -8,21 +8,24 @@ except PackageNotFoundError:
8
8
  from matrix._version import version as __version__
9
9
 
10
10
  from .bot import Bot
11
- from .group import Group
11
+ from .group import Group, group
12
12
  from .config import Config
13
13
  from .context import Context
14
14
  from .command import Command
15
15
  from .help import HelpCommand
16
16
  from .checks import cooldown
17
17
  from .room import Room
18
+ from .extension import Extension
18
19
 
19
20
  __all__ = [
20
21
  "Bot",
21
22
  "Group",
23
+ "group",
22
24
  "Config",
23
25
  "Command",
24
26
  "Context",
25
27
  "HelpCommand",
26
28
  "cooldown",
27
29
  "Room",
30
+ "Extension",
28
31
  ]
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.1.0a0'
32
- __version_tuple__ = version_tuple = (1, 1, 0, 'a0')
31
+ __version__ = version = '1.3.0a0'
32
+ __version_tuple__ = version_tuple = (1, 3, 0, 'a0')
33
33
 
34
- __commit_id__ = commit_id = 'gcd36e40dc'
34
+ __commit_id__ = commit_id = 'gfee3d765e'
@@ -0,0 +1,288 @@
1
+ import time
2
+ import inspect
3
+ import asyncio
4
+ import logging
5
+
6
+ from typing import Union, Optional
7
+
8
+ from nio import AsyncClient, Event, MatrixRoom
9
+
10
+ from .room import Room
11
+ from .group import Group
12
+ from .config import Config
13
+ from .context import Context
14
+ from .extension import Extension
15
+ from .registry import Registry
16
+ from .help import HelpCommand, DefaultHelpCommand
17
+ from .scheduler import Scheduler
18
+ from .errors import AlreadyRegisteredError, CommandNotFoundError, CheckError
19
+
20
+
21
+ class Bot(Registry):
22
+ """
23
+ The base class defining a Matrix bot.
24
+
25
+ This class manages the connection to a Matrix homeserver, listens
26
+ for events, and dispatches them to registered handlers. It also supports
27
+ a command system with decorators for easy registration.
28
+ """
29
+
30
+ def __init__(
31
+ self, *, config: Union[Config, str], help: Optional[HelpCommand] = None
32
+ ) -> None:
33
+ if isinstance(config, Config):
34
+ self.config = config
35
+ elif isinstance(config, str):
36
+ self.config = Config(config_path=config)
37
+ else:
38
+ raise TypeError("config must be a Config instance or a config file path")
39
+
40
+ super().__init__(self.__class__.__name__, prefix=self.config.prefix)
41
+
42
+ self.client: AsyncClient = AsyncClient(self.config.homeserver)
43
+ self.extensions: dict[str, Extension] = {}
44
+ self.scheduler: Scheduler = Scheduler()
45
+ self.log: logging.Logger = logging.getLogger(__name__)
46
+
47
+ self.start_at: float | None = None # unix timestamp
48
+
49
+ self.help: HelpCommand = help or DefaultHelpCommand(prefix=self.prefix)
50
+ self.register_command(self.help)
51
+
52
+ self.client.add_event_callback(self._on_event, Event)
53
+ self._auto_register_events()
54
+
55
+ def get_room(self, room_id: str) -> Room:
56
+ """Retrieve a Room instance based on the room_id."""
57
+ matrix_room = self.client.rooms[room_id]
58
+ return Room(matrix_room=matrix_room, client=self.client)
59
+
60
+ def load_extension(self, extension: Extension) -> None:
61
+ self.log.debug(f"Loading extension: '{extension.name}'")
62
+
63
+ if extension.name in self.extensions:
64
+ raise AlreadyRegisteredError(extension)
65
+
66
+ for cmd in extension._commands.values():
67
+ if isinstance(cmd, Group):
68
+ self.register_group(cmd)
69
+ else:
70
+ self.register_command(cmd)
71
+
72
+ for event_type, handlers in extension._event_handlers.items():
73
+ self._event_handlers[event_type].extend(handlers)
74
+
75
+ self._checks.extend(extension._checks)
76
+ self._error_handlers.update(extension._error_handlers)
77
+ self._command_error_handlers.update(extension._command_error_handlers)
78
+
79
+ for job in extension._scheduler.jobs:
80
+ self.scheduler.scheduler.add_job(
81
+ job.func,
82
+ trigger=job.trigger,
83
+ name=job.name,
84
+ )
85
+
86
+ self.extensions[extension.name] = extension
87
+ extension.load()
88
+ self.log.debug("loaded extension '%s'", extension.name)
89
+
90
+ def unload_extension(self, ext_name: str) -> None:
91
+ self.log.debug("Unloading extension: '%s'", ext_name)
92
+
93
+ extension = self.extensions.pop(ext_name, None)
94
+ if extension is None:
95
+ raise ValueError(f"No extension named '{ext_name}' is loaded")
96
+
97
+ for cmd_name in extension._commands:
98
+ self._commands.pop(cmd_name, None)
99
+
100
+ for event_type, handlers in extension._event_handlers.items():
101
+ for handler in handlers:
102
+ self._event_handlers[event_type].remove(handler)
103
+
104
+ for check in extension._checks:
105
+ self._checks.remove(check)
106
+
107
+ for exc_type in extension._error_handlers:
108
+ self._error_handlers.pop(exc_type, None)
109
+
110
+ for exc_type in extension._command_error_handlers:
111
+ self._command_error_handlers.pop(exc_type, None)
112
+
113
+ for job in extension._scheduler.jobs:
114
+ bot_job = next((j for j in self.scheduler.jobs if j.func is job.func), None)
115
+ if bot_job:
116
+ bot_job.remove()
117
+
118
+ extension.unload()
119
+ self.log.debug("unloaded extension '%s'", ext_name)
120
+
121
+ def _auto_register_events(self) -> None:
122
+ for attr in dir(self):
123
+ if not attr.startswith("on_"):
124
+ continue
125
+ coro = getattr(self, attr, None)
126
+ if inspect.iscoroutinefunction(coro):
127
+ try:
128
+ self.event(coro)
129
+ except ValueError: # ignore unknown name
130
+ continue
131
+
132
+ async def _on_event(self, room: MatrixRoom, event: Event) -> None:
133
+ # ignore bot events
134
+ if event.sender == self.client.user:
135
+ return
136
+
137
+ # ignore events that happened before the bot started
138
+ if self.start_at and self.start_at > (event.server_timestamp / 1000):
139
+ return
140
+
141
+ try:
142
+ await self._dispatch(room, event)
143
+ except Exception as error:
144
+ await self.on_error(error)
145
+
146
+ async def _dispatch(self, room: MatrixRoom, event: Event) -> None:
147
+ """Internal type-based fan-out plus optional command handling."""
148
+ for event_type, funcs in self._event_handlers.items():
149
+ if isinstance(event, event_type):
150
+ for func in funcs:
151
+ await func(room, event)
152
+
153
+ async def _process_commands(self, room: MatrixRoom, event: Event) -> None:
154
+ """Parse and execute commands"""
155
+ ctx = await self._build_context(room, event)
156
+
157
+ if ctx.command:
158
+ for check in self._checks:
159
+ if not await check(ctx):
160
+ raise CheckError(ctx.command, check)
161
+
162
+ await ctx.command(ctx)
163
+
164
+ async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context:
165
+ room = self.get_room(matrix_room.room_id)
166
+ ctx = Context(bot=self, room=room, event=event)
167
+ prefix: str | None = None
168
+
169
+ if self.prefix is not None and ctx.body.startswith(self.prefix):
170
+ prefix = self.prefix
171
+ else:
172
+ prefix = next(
173
+ (
174
+ cmd.prefix
175
+ for cmd in self._commands.values()
176
+ if cmd.prefix is not None and ctx.body.startswith(cmd.prefix)
177
+ ),
178
+ self.config.prefix,
179
+ )
180
+
181
+ if prefix is None or not ctx.body.startswith(prefix):
182
+ return ctx
183
+
184
+ if parts := ctx.body[len(prefix) :].split():
185
+ cmd_name = parts[0]
186
+ cmd = self._commands.get(cmd_name)
187
+
188
+ if cmd and cmd.prefix and not ctx.body.startswith(cmd.prefix):
189
+ return ctx
190
+
191
+ if not cmd:
192
+ raise CommandNotFoundError(cmd_name)
193
+
194
+ ctx.command = cmd
195
+
196
+ return ctx
197
+
198
+ async def on_message(self, room: MatrixRoom, event: Event) -> None:
199
+ """
200
+ Invoked when a message event is received.
201
+
202
+ This method is automatically called when a :class:`nio.RoomMessageText`
203
+ event is detected. It is primarily responsible for detecting and
204
+ processing commands that match the bot's defined prefix.
205
+
206
+ :param ctx: The context object containing information about the Matrix
207
+ room and the message event.
208
+ :type ctx: Context
209
+ """
210
+ await self._process_commands(room, event)
211
+
212
+ async def on_ready(self) -> None:
213
+ """Invoked after a successful login, before sync starts."""
214
+ self.log.info("bot is ready")
215
+
216
+ async def on_error(self, error: Exception) -> None:
217
+ """
218
+ Handle errors by invoking a registered error handler,
219
+ a generic error callback, or logging the exception.
220
+
221
+ :param error: The exception instance that was raised.
222
+ :type error: Exceptipon
223
+ """
224
+ if handler := self._error_handlers.get(type(error)):
225
+ await handler(error)
226
+ return
227
+
228
+ if self._on_error:
229
+ await self._on_error(error)
230
+ return
231
+ self.log.exception("Unhandled error: '%s'", error)
232
+
233
+ async def on_command_error(self, ctx: "Context", error: Exception) -> None:
234
+ """
235
+ Handles errors raised during command invocation.
236
+
237
+ This method is called automatically when a command error occurs.
238
+ If a specific error handler is registered for the type of the
239
+ exception, it will be invoked with the current context and error.
240
+
241
+ :param ctx: The context in which the command was invoked.
242
+ :type ctx: Context
243
+ :param error: The exception that was raised during command execution.
244
+ :type error: Exception
245
+ """
246
+ if handler := self._command_error_handlers.get(type(error)):
247
+ await handler(ctx, error)
248
+
249
+ async def run(self) -> None:
250
+ """
251
+ Log in to the Matrix homeserver and begin syncing events.
252
+
253
+ This method should be used within an asynchronous context,
254
+ typically via :func:`asyncio.run`. It handles authentication,
255
+ calls the :meth:`on_ready` hook, and starts the long-running
256
+ sync loop for receiving events.
257
+ """
258
+ self.client.user = self.config.user_id
259
+
260
+ self.start_at = time.time()
261
+ self.log.info("starting – timestamp=%s", self.start_at)
262
+
263
+ if self.config.token:
264
+ self.client.access_token = self.config.token
265
+ else:
266
+ login_resp = await self.client.login(self.config.password)
267
+ self.log.info("logged in: %s", login_resp)
268
+
269
+ self.scheduler.start()
270
+
271
+ await self.on_ready()
272
+ await self.client.sync_forever(timeout=30_000)
273
+
274
+ def start(self) -> None:
275
+ """
276
+ Synchronous entry point for running the bot.
277
+
278
+ This is a convenience wrapper that allows running the bot like a
279
+ script using a blocking call. It internally calls :meth:`run` within
280
+ :func:`asyncio.run`, and ensures the client is closed gracefully
281
+ on interruption.
282
+ """
283
+ try:
284
+ asyncio.run(self.run())
285
+ except KeyboardInterrupt:
286
+ self.log.info("bot interrupted by user")
287
+ finally:
288
+ asyncio.run(self.client.close())
@@ -298,7 +298,6 @@ class Command:
298
298
  await ctx.send_help()
299
299
 
300
300
  ctx.logger.exception("error while executing command '%s'", self)
301
- raise error
302
301
 
303
302
  async def invoke(self, ctx: "Context") -> None:
304
303
  parsed_args = self._parse_arguments(ctx)
@@ -28,7 +28,6 @@ class Context:
28
28
  self.sender: str = event.sender
29
29
 
30
30
  # Command metadata
31
- self.prefix: str = bot.prefix
32
31
  self.command: Optional[Command] = None
33
32
  self.subcommand: Optional[Command] = None
34
33
  self._args: List[str] = shlex.split(self.body)
@@ -4,6 +4,7 @@ import inspect
4
4
  if TYPE_CHECKING:
5
5
  from .command import Command # pragma: no cover
6
6
  from .group import Group # pragma: no cover
7
+ from .extension import Extension
7
8
 
8
9
  Callback = Callable[..., Coroutine[Any, Any, Any]]
9
10
 
@@ -12,6 +13,17 @@ class MatrixError(Exception):
12
13
  pass
13
14
 
14
15
 
16
+ class RegistryError(MatrixError):
17
+ pass
18
+
19
+
20
+ class AlreadyRegisteredError(RegistryError):
21
+ def __init__(self, entry: "Command | Group | Extension"):
22
+ super().__init__(
23
+ f"{entry.__class__.__name__} '{entry.name}' is already registered"
24
+ )
25
+
26
+
15
27
  class CommandError(MatrixError):
16
28
  pass
17
29
 
@@ -21,7 +33,7 @@ class CommandNotFoundError(CommandError):
21
33
  super().__init__(f"Command with name '{cmd}' not found")
22
34
 
23
35
 
24
- class AlreadyRegisteredError(CommandError):
36
+ class CommandAlreadyRegisteredError(CommandError):
25
37
  def __init__(self, cmd: "Command"):
26
38
  super().__init__(f"Command '{cmd}' is already registered")
27
39
 
@@ -40,11 +52,6 @@ class GroupError(CommandError):
40
52
  pass
41
53
 
42
54
 
43
- class GroupAlreadyRegisteredError(GroupError):
44
- def __init__(self, group: "Group"):
45
- super().__init__(f"Group '{group}' is already registered")
46
-
47
-
48
55
  class ConfigError(MatrixError):
49
56
  def __init__(self, error: str):
50
57
  super().__init__(f"Missing required configuration: '{error}'")
@@ -0,0 +1,56 @@
1
+ import logging
2
+ import inspect
3
+
4
+ from typing import Any, Callable, Coroutine, Optional
5
+ from matrix.registry import Registry
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class Extension(Registry):
11
+ def __init__(self, name: str, prefix: Optional[str] = None) -> None:
12
+ super().__init__(name, prefix=prefix)
13
+ self._on_load: Optional[Callable] = None
14
+ self._on_unload: Optional[Callable] = None
15
+
16
+ def load(self) -> None:
17
+ if self._on_load:
18
+ self._on_load()
19
+
20
+ def on_load(self, func: Callable) -> Callable:
21
+ """Decorator to register a function to be called after this extension
22
+ is loaded into the bot.
23
+
24
+ ## Example
25
+
26
+ ```python
27
+ @extension.on_load
28
+ def setup():
29
+ print("extension loaded")
30
+ ```
31
+ """
32
+ if inspect.iscoroutinefunction(func):
33
+ raise TypeError("on_load handler must not be a coroutine")
34
+ self._on_load = func
35
+ return func
36
+
37
+ def unload(self) -> None:
38
+ if self._on_unload:
39
+ self._on_unload()
40
+
41
+ def on_unload(self, func: Callable) -> Callable:
42
+ """Decorator to register a function to be called before this extension
43
+ is unloaded from the bot.
44
+
45
+ ## Example
46
+
47
+ ```python
48
+ @extension.on_unload
49
+ def teardown():
50
+ print("extension unloaded")
51
+ ```
52
+ """
53
+ if inspect.iscoroutinefunction(func):
54
+ raise TypeError("on_unload handler must not be a coroutine")
55
+ self._on_unload = func
56
+ return func
@@ -82,3 +82,49 @@ class Group(Command):
82
82
  await ctx.subcommand(ctx)
83
83
  else:
84
84
  await self.callback(ctx)
85
+
86
+
87
+ def group(
88
+ name: str,
89
+ *,
90
+ description: Optional[str] = None,
91
+ prefix: Optional[str] = None,
92
+ parent: Optional[str] = None,
93
+ usage: Optional[str] = None,
94
+ cooldown: Optional[tuple[int, float]] = None,
95
+ ) -> Callable[[Callback], Group]:
96
+ """
97
+ Decorator to create a group with a callback.
98
+
99
+ This is equivalent to @bot.group() but for creating groups
100
+ without immediately registering them to a bot.
101
+
102
+ ## Example
103
+
104
+ ```python
105
+ @group("math", description="Math operations")
106
+ async def math(ctx):
107
+ await ctx.reply("Math help")
108
+
109
+
110
+ @math.command()
111
+ async def add(ctx, a: int, b: int):
112
+ await ctx.reply(f"{a + b}")
113
+
114
+
115
+ bot.register_group(math)
116
+ ```
117
+ """
118
+
119
+ def decorator(func: Callback) -> Group:
120
+ return Group(
121
+ func,
122
+ name=name,
123
+ description=description,
124
+ prefix=prefix,
125
+ parent=parent,
126
+ usage=usage,
127
+ cooldown=cooldown,
128
+ )
129
+
130
+ return decorator