matrix-python 1.3.3a0__tar.gz → 1.4.1a0__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 (64) hide show
  1. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/PKG-INFO +1 -1
  2. matrix_python-1.4.1a0/matrix/_version.py +24 -0
  3. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/bot.py +131 -123
  4. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/extension.py +16 -3
  5. matrix_python-1.4.1a0/matrix/protocols.py +9 -0
  6. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/registry.py +77 -13
  7. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix_python.egg-info/PKG-INFO +1 -1
  8. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix_python.egg-info/SOURCES.txt +1 -0
  9. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_bot.py +38 -25
  10. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_extension.py +92 -6
  11. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_registry.py +4 -2
  12. matrix_python-1.3.3a0/matrix/_version.py +0 -34
  13. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/.github/dependabot.yml +0 -0
  14. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/.github/workflows/CODEOWNERS +0 -0
  15. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/.github/workflows/codeql.yml +0 -0
  16. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/.github/workflows/publish.yml +0 -0
  17. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/.github/workflows/scorecard.yml +0 -0
  18. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/.github/workflows/tests.yml +0 -0
  19. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/.gitignore +0 -0
  20. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/CODE_OF_CONDUCT.md +0 -0
  21. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/CONTRIBUTING.md +0 -0
  22. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/LICENSE +0 -0
  23. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/README.md +0 -0
  24. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/README.md +0 -0
  25. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/checks.py +0 -0
  26. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/config.yaml +0 -0
  27. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/cooldown.py +0 -0
  28. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/error_handling.py +0 -0
  29. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/extension.py +0 -0
  30. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/ping.py +0 -0
  31. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/reaction.py +0 -0
  32. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/examples/scheduler.py +0 -0
  33. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/__init__.py +0 -0
  34. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/checks.py +0 -0
  35. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/command.py +0 -0
  36. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/config.py +0 -0
  37. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/content.py +0 -0
  38. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/context.py +0 -0
  39. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/errors.py +0 -0
  40. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/group.py +0 -0
  41. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/help/__init__.py +0 -0
  42. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/help/help_command.py +0 -0
  43. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/help/pagination.py +0 -0
  44. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/message.py +0 -0
  45. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/room.py +0 -0
  46. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/scheduler.py +0 -0
  47. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix/types.py +0 -0
  48. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix_python.egg-info/dependency_links.txt +0 -0
  49. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix_python.egg-info/requires.txt +0 -0
  50. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/matrix_python.egg-info/top_level.txt +0 -0
  51. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/mypy.ini +0 -0
  52. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/pyproject.toml +0 -0
  53. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/setup.cfg +0 -0
  54. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/config_fixture.yaml +0 -0
  55. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/config_fixture_token.yaml +0 -0
  56. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/help/test_default_help_command.py +0 -0
  57. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/help/test_help_command.py +0 -0
  58. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/help/test_pagination.py +0 -0
  59. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_command.py +0 -0
  60. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_config.py +0 -0
  61. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_context.py +0 -0
  62. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_group.py +0 -0
  63. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_message.py +0 -0
  64. {matrix_python-1.3.3a0 → matrix_python-1.4.1a0}/tests/test_room.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrix-python
3
- Version: 1.3.3a0
3
+ Version: 1.4.1a0
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,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.4.1a0'
22
+ __version_tuple__ = version_tuple = (1, 4, 1, 'a0')
23
+
24
+ __commit_id__ = commit_id = 'gcd71354ad'
@@ -3,7 +3,7 @@ import inspect
3
3
  import asyncio
4
4
  import logging
5
5
 
6
- from typing import Union, Optional
6
+ from typing import Union, Optional, Any
7
7
 
8
8
  from nio import AsyncClient, Event, MatrixRoom
9
9
 
@@ -49,9 +49,27 @@ class Bot(Registry):
49
49
  self.help: HelpCommand = help or DefaultHelpCommand(prefix=self.prefix)
50
50
  self.register_command(self.help)
51
51
 
52
- self.client.add_event_callback(self._on_event, Event)
52
+ self.client.add_event_callback(self._on_matrix_event, Event)
53
53
  self._auto_register_events()
54
54
 
55
+ def _auto_register_events(self) -> None:
56
+ for attr in dir(self):
57
+ if not attr.startswith("on_"):
58
+ continue
59
+
60
+ coro = getattr(self, attr, None)
61
+ if not inspect.iscoroutinefunction(coro):
62
+ continue
63
+
64
+ try:
65
+ if attr in self.LIFECYCLE_EVENTS:
66
+ self.hook(coro)
67
+
68
+ if attr in self.EVENT_MAP:
69
+ self.event(coro)
70
+ except ValueError:
71
+ continue
72
+
55
73
  def get_room(self, room_id: str) -> Room:
56
74
  """Retrieve a Room instance based on the room_id."""
57
75
  matrix_room = self.client.rooms[room_id]
@@ -72,6 +90,9 @@ class Bot(Registry):
72
90
  for event_type, handlers in extension._event_handlers.items():
73
91
  self._event_handlers[event_type].extend(handlers)
74
92
 
93
+ for hook_name, handlers in extension._hook_handlers.items():
94
+ self._hook_handlers[hook_name].extend(handlers)
95
+
75
96
  self._checks.extend(extension._checks)
76
97
  self._error_handlers.update(extension._error_handlers)
77
98
  self._command_error_handlers.update(extension._command_error_handlers)
@@ -84,7 +105,7 @@ class Bot(Registry):
84
105
  )
85
106
 
86
107
  self.extensions[extension.name] = extension
87
- extension.load()
108
+ extension.load(self)
88
109
  self.log.debug("loaded extension '%s'", extension.name)
89
110
 
90
111
  def unload_extension(self, ext_name: str) -> None:
@@ -118,133 +139,74 @@ class Bot(Registry):
118
139
  extension.unload()
119
140
  self.log.debug("unloaded extension '%s'", ext_name)
120
141
 
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)
142
+ # LIFECYCLE
211
143
 
212
144
  async def on_ready(self) -> None:
213
- """Invoked after a successful login, before sync starts."""
214
- self.log.info("bot is ready")
145
+ """Override this in a subclass."""
146
+ pass
147
+
148
+ async def _on_ready(self) -> None:
149
+ """Internal hook — always fires, calls public override then extension handlers."""
150
+ await self.on_ready()
151
+ await self._dispatch("on_ready")
215
152
 
216
153
  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.
154
+ """Override this in a subclass."""
155
+ self.log.exception("Unhandled error: '%s'", error)
220
156
 
221
- :param error: The exception instance that was raised.
222
- :type error: Exceptipon
223
- """
157
+ async def _on_error(self, error: Exception) -> None:
224
158
  if handler := self._error_handlers.get(type(error)):
225
159
  await handler(error)
226
160
  return
227
161
 
228
- if self._on_error:
229
- await self._on_error(error)
162
+ if self._fallback_error_handler:
163
+ await self._fallback_error_handler(error)
230
164
  return
165
+
166
+ await self._dispatch("on_error", error)
167
+
168
+ async def on_command(self, _ctx: Context) -> None:
169
+ """Override this in a subclass."""
170
+ pass
171
+
172
+ async def _on_command(self, ctx: Context) -> None:
173
+ await self._dispatch("on_command", ctx)
174
+
175
+ async def on_command_error(self, _ctx: Context, error: Exception) -> None:
176
+ """Override this in a subclass."""
231
177
  self.log.exception("Unhandled error: '%s'", error)
232
178
 
233
- async def on_command_error(self, ctx: "Context", error: Exception) -> None:
179
+ async def _on_command_error(self, ctx: Context, error: Exception) -> None:
234
180
  """
235
181
  Handles errors raised during command invocation.
236
182
 
237
183
  This method is called automatically when a command error occurs.
238
184
  If a specific error handler is registered for the type of the
239
185
  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
186
  """
246
187
  if handler := self._command_error_handlers.get(type(error)):
247
188
  await handler(ctx, error)
189
+ return
190
+
191
+ await self._dispatch("on_command_error", ctx, error)
192
+
193
+ # ENTRYPOINT
194
+
195
+ def start(self) -> None:
196
+ """
197
+ Synchronous entry point for running the bot.
198
+
199
+ This is a convenience wrapper that allows running the bot like a
200
+ script using a blocking call. It internally calls :meth:`run` within
201
+ :func:`asyncio.run`, and ensures the client is closed gracefully
202
+ on interruption.
203
+ """
204
+ try:
205
+ asyncio.run(self.run())
206
+ except KeyboardInterrupt:
207
+ self.log.info("bot interrupted by user")
208
+ finally:
209
+ asyncio.run(self.client.close())
248
210
 
249
211
  async def run(self) -> None:
250
212
  """
@@ -268,21 +230,67 @@ class Bot(Registry):
268
230
 
269
231
  self.scheduler.start()
270
232
 
271
- await self.on_ready()
233
+ await self._on_ready()
272
234
  await self.client.sync_forever(timeout=30_000)
273
235
 
274
- def start(self) -> None:
275
- """
276
- Synchronous entry point for running the bot.
236
+ # MATRIX EVENTS
237
+
238
+ async def on_message(self, room: MatrixRoom, event: Event) -> None:
239
+ await self._process_commands(room, event)
240
+
241
+ async def _on_matrix_event(self, room: MatrixRoom, event: Event) -> None:
242
+ # ignore bot events
243
+ if event.sender == self.client.user:
244
+ return
245
+
246
+ # ignore events that happened before the bot started
247
+ if self.start_at and self.start_at > (event.server_timestamp / 1000):
248
+ return
277
249
 
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
250
  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())
251
+ await self._dispatch_matrix_event(room, event)
252
+ except Exception as error:
253
+ await self._on_error(error)
254
+
255
+ async def _dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> None:
256
+ """Fire all listeners registered for a named lifecycle event."""
257
+ for handler in self._hook_handlers.get(event_name, []):
258
+ await handler(*args, **kwargs)
259
+
260
+ async def _dispatch_matrix_event(self, room: MatrixRoom, event: Event) -> None:
261
+ """Fire all listeners registered for a named matrix event."""
262
+ for event_type, funcs in self._event_handlers.items():
263
+ if isinstance(event, event_type):
264
+ for func in funcs:
265
+ await func(room, event)
266
+
267
+ async def _process_commands(self, room: MatrixRoom, event: Event) -> None:
268
+ """Parse and execute commands"""
269
+ ctx = await self._build_context(room, event)
270
+
271
+ if ctx.command:
272
+ for check in self._checks:
273
+ if not await check(ctx):
274
+ raise CheckError(ctx.command, check)
275
+
276
+ await self._on_command(ctx)
277
+ await ctx.command(ctx)
278
+
279
+ async def _build_context(self, matrix_room: MatrixRoom, event: Event) -> Context:
280
+ room = self.get_room(matrix_room.room_id)
281
+ ctx = Context(bot=self, room=room, event=event)
282
+ prefix = self.prefix or self.config.prefix
283
+
284
+ if not ctx.body.startswith(prefix):
285
+ return ctx
286
+
287
+ if parts := ctx.body[len(prefix) :].split():
288
+ cmd_name = parts[0]
289
+ cmd = self._commands.get(cmd_name)
290
+
291
+ if not cmd:
292
+ raise CommandNotFoundError(cmd_name)
293
+
294
+ ctx.command = cmd
295
+
296
+ return ctx
@@ -1,8 +1,10 @@
1
- import logging
2
1
  import inspect
2
+ import logging
3
+ from typing import Callable, Optional
3
4
 
4
- from typing import Any, Callable, Coroutine, Optional
5
+ from matrix.protocols import BotLike
5
6
  from matrix.registry import Registry
7
+ from matrix.room import Room
6
8
 
7
9
  logger = logging.getLogger(__name__)
8
10
 
@@ -10,10 +12,19 @@ logger = logging.getLogger(__name__)
10
12
  class Extension(Registry):
11
13
  def __init__(self, name: str, prefix: Optional[str] = None) -> None:
12
14
  super().__init__(name, prefix=prefix)
15
+
16
+ self.bot: Optional[BotLike] = None
13
17
  self._on_load: Optional[Callable] = None
14
18
  self._on_unload: Optional[Callable] = None
15
19
 
16
- def load(self) -> None:
20
+ def get_room(self, room_id: str) -> Room:
21
+ if self.bot is None:
22
+ raise RuntimeError("Extension is not loaded")
23
+ return self.bot.get_room(room_id)
24
+
25
+ def load(self, bot: BotLike) -> None:
26
+ self.bot = bot
27
+
17
28
  if self._on_load:
18
29
  self._on_load()
19
30
 
@@ -35,6 +46,8 @@ class Extension(Registry):
35
46
  return func
36
47
 
37
48
  def unload(self) -> None:
49
+ self.bot = None
50
+
38
51
  if self._on_unload:
39
52
  self._on_unload()
40
53
 
@@ -0,0 +1,9 @@
1
+ from typing import Protocol
2
+
3
+ from matrix.room import Room
4
+
5
+
6
+ class BotLike(Protocol):
7
+ prefix: str | None
8
+
9
+ def get_room(self, room_id: str) -> Room: ...
@@ -47,6 +47,13 @@ class Registry:
47
47
  "on_member_change": RoomMemberEvent,
48
48
  }
49
49
 
50
+ LIFECYCLE_EVENTS: set[str] = {
51
+ "on_ready",
52
+ "on_error",
53
+ "on_command",
54
+ "on_command_error",
55
+ }
56
+
50
57
  def __init__(self, name: str, prefix: Optional[str] = None):
51
58
  self.name = name
52
59
  self.prefix = prefix
@@ -57,7 +64,8 @@ class Registry:
57
64
  self._scheduler: Scheduler = Scheduler()
58
65
 
59
66
  self._event_handlers: Dict[Type[Event], List[Callback]] = defaultdict(list)
60
- self._on_error: Optional[ErrorCallback] = None
67
+ self._hook_handlers: Dict[str, List[Callback]] = defaultdict(list)
68
+ self._fallback_error_handler: Optional[ErrorCallback] = None
61
69
  self._error_handlers: Dict[type[Exception], ErrorCallback] = {}
62
70
  self._command_error_handlers: Dict[type[Exception], CommandErrorCallback] = {}
63
71
 
@@ -208,17 +216,15 @@ class Registry:
208
216
  if not inspect.iscoroutinefunction(f):
209
217
  raise TypeError("Event handlers must be coroutines")
210
218
 
211
- if event_spec:
212
- if isinstance(event_spec, str):
213
- event_type = self.EVENT_MAP.get(event_spec)
214
- if event_type is None:
215
- raise ValueError(f"Unknown event string: {event_spec}")
216
- else:
217
- event_type = event_spec
218
- else:
219
- event_type = self.EVENT_MAP.get(f.__name__)
220
- if event_type is None:
221
- raise ValueError(f"Unknown event name: {f.__name__}")
219
+ key = event_spec if isinstance(event_spec, str) else f.__name__
220
+ event_type: type[Event] | None = (
221
+ event_spec
222
+ if event_spec and not isinstance(event_spec, str)
223
+ else self.EVENT_MAP.get(key)
224
+ )
225
+
226
+ if event_type is None:
227
+ raise ValueError(f"Unknown event: {key!r}")
222
228
 
223
229
  return self.register_event(event_type, f)
224
230
 
@@ -238,6 +244,64 @@ class Registry:
238
244
  )
239
245
  return callback
240
246
 
247
+ def hook(
248
+ self, func: Optional[Callback] = None, *, event_name: Optional[str] = None
249
+ ) -> Union[Callback, Callable[[Callback], Callback]]:
250
+ """Decorator to register a coroutine as a lifecycle event hook.
251
+
252
+ Lifecycle events include things like ``on_ready``, ``on_command``,
253
+ and ``on_error``. If the event name is not provided, it is inferred
254
+ from the function name. Multiple handlers for the same lifecycle
255
+ event are supported and called in registration order.
256
+
257
+ ## Example
258
+
259
+ ```python
260
+ @bot.hook
261
+ async def on_ready():
262
+ print("Bot is ready!")
263
+
264
+ @bot.hook(event_name="on_command")
265
+ async def log_command(ctx):
266
+ print(f"Command invoked: {ctx.command}")
267
+ ```
268
+ """
269
+
270
+ def wrapper(f: Callback) -> Callback:
271
+ if not inspect.iscoroutinefunction(f):
272
+ raise TypeError("Lifecycle hooks must be coroutines")
273
+
274
+ name = event_name or f.__name__
275
+ if name not in self.LIFECYCLE_EVENTS:
276
+ raise ValueError(f"Unknown lifecycle event: {name}")
277
+
278
+ return self.register_hook(name, f)
279
+
280
+ if func is None:
281
+ return wrapper
282
+ return wrapper(func)
283
+
284
+ def register_hook(self, event_name: str, callback: Callback) -> Callback:
285
+ """Register a lifecycle event hook directly for a given event name.
286
+
287
+ Prefer the :meth:`hook` decorator for typical use. This method
288
+ is useful when loading lifecycle hooks from an extension.
289
+ """
290
+ if not inspect.iscoroutinefunction(callback):
291
+ raise TypeError("Lifecycle hooks must be coroutines")
292
+
293
+ if event_name not in self.LIFECYCLE_EVENTS:
294
+ raise ValueError(f"Unknown lifecycle event: {event_name}")
295
+
296
+ self._hook_handlers[event_name].append(callback)
297
+ logger.debug(
298
+ "registered lifecycle hook '%s' for event '%s' on %s",
299
+ callback.__name__,
300
+ event_name,
301
+ type(self).__name__,
302
+ )
303
+ return callback
304
+
241
305
  def check(self, func: Callback) -> Callback:
242
306
  """Register a global check that must pass before any command is invoked.
243
307
 
@@ -321,7 +385,7 @@ class Registry:
321
385
  if exception:
322
386
  self._error_handlers[exception] = func
323
387
  else:
324
- self._on_error = func
388
+ self._fallback_error_handler = func
325
389
  logger.debug(
326
390
  "registered error handler '%s' on %s",
327
391
  func.__name__,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrix-python
3
- Version: 1.3.3a0
3
+ Version: 1.4.1a0
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>
@@ -32,6 +32,7 @@ matrix/errors.py
32
32
  matrix/extension.py
33
33
  matrix/group.py
34
34
  matrix/message.py
35
+ matrix/protocols.py
35
36
  matrix/registry.py
36
37
  matrix/room.py
37
38
  matrix/scheduler.py
@@ -64,16 +64,15 @@ def test_bot_init_with_invalid_config_file():
64
64
 
65
65
 
66
66
  def test_auto_register_events_registers_known_events(bot):
67
- # Add a dummy coroutine named on_message_known to bot instance
68
- async def on_message_known(room, event):
67
+ async def on_message(room, event):
69
68
  pass
70
69
 
71
- setattr(bot, "on_message_known", on_message_known)
70
+ setattr(bot, "on_message", on_message)
72
71
 
73
- with patch.object(bot, "event", wraps=bot.event) as event:
72
+ with patch.object(bot, "event", wraps=bot.event) as mock_event:
74
73
  bot._auto_register_events()
75
74
 
76
- event.assert_any_call(on_message_known)
75
+ mock_event.assert_any_call(on_message)
77
76
 
78
77
 
79
78
  @pytest.mark.asyncio
@@ -100,7 +99,7 @@ async def test_dispatch_calls_all_handlers(bot):
100
99
  )
101
100
  room = MatrixRoom("!roomid:matrix.org", "room_alias")
102
101
 
103
- await bot._dispatch(room, event)
102
+ await bot._dispatch_matrix_event(room, event)
104
103
  assert "h1" in called
105
104
  assert "h2" in called
106
105
 
@@ -114,26 +113,27 @@ async def test_on_event_ignores_self_events(bot):
114
113
  event.sender = "@grace:matrix.org"
115
114
  event.server_timestamp = 123456789
116
115
 
117
- with patch.object(bot, "_dispatch", new_callable=AsyncMock) as dispatch:
118
- await bot._on_event(MatrixRoom("!room:matrix.org", "alias"), event)
116
+ with patch.object(
117
+ bot, "_dispatch_matrix_event", new_callable=AsyncMock
118
+ ) as dispatch:
119
+ await bot._on_matrix_event(MatrixRoom("!room:matrix.org", "alias"), event)
119
120
  dispatch.assert_not_called()
120
121
 
121
122
 
122
123
  @pytest.mark.asyncio
123
124
  async def test_on_event_ignores_old_events(bot, room, event):
124
- # Set start_at after event time
125
125
  bot.client.user = "@somebot:matrix.org"
126
126
  bot.start_at = event.server_timestamp / 1000 + 10
127
127
 
128
- bot._dispatch = AsyncMock()
129
- await bot._on_event(room, event)
128
+ bot._dispatch_matrix_event = AsyncMock()
129
+ await bot._on_matrix_event(room, event)
130
130
 
131
- bot._dispatch.assert_not_called()
131
+ bot._dispatch_matrix_event.assert_not_called()
132
132
 
133
133
 
134
134
  @pytest.mark.asyncio
135
135
  async def test_on_event_calls_error_handler(bot):
136
- bot._dispatch = AsyncMock(side_effect=Exception("boom"))
136
+ bot._dispatch_matrix_event = AsyncMock(side_effect=Exception("boom"))
137
137
 
138
138
  custom_error_handler = AsyncMock()
139
139
  bot.error()(custom_error_handler)
@@ -144,7 +144,7 @@ async def test_on_event_calls_error_handler(bot):
144
144
  bot.start_at = 0
145
145
  bot.client.user = "@grace:matrix.org"
146
146
 
147
- await bot._on_event(MatrixRoom("!roomid", "alias"), event)
147
+ await bot._on_matrix_event(MatrixRoom("!roomid", "alias"), event)
148
148
  custom_error_handler.assert_awaited_once()
149
149
 
150
150
 
@@ -156,13 +156,28 @@ async def test_on_message_calls_process_commands(bot, room, event):
156
156
 
157
157
 
158
158
  @pytest.mark.asyncio
159
- async def test_on_ready(bot):
160
- await bot.on_ready()
161
- bot.log.info.assert_called_once_with("bot is ready")
159
+ async def test_on_ready_dispatches(bot):
160
+ with patch.object(bot, "_dispatch", new_callable=AsyncMock) as mock_dispatch:
161
+ await bot._on_ready()
162
+ mock_dispatch.assert_awaited_once_with("on_ready")
162
163
 
163
164
 
164
165
  @pytest.mark.asyncio
165
- async def test_on_error_calls_custom_handler(bot):
166
+ async def test_on_error_calls_specific_handler(bot):
167
+ called = False
168
+
169
+ @bot.error(ValueError)
170
+ async def custom_error_handler(e):
171
+ nonlocal called
172
+ called = True
173
+
174
+ await bot._on_error(ValueError("test error"))
175
+
176
+ assert called, "Specific error handler was not called"
177
+
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_on_error_calls_fallback_handler(bot):
166
181
  called = False
167
182
 
168
183
  @bot.error()
@@ -170,15 +185,14 @@ async def test_on_error_calls_custom_handler(bot):
170
185
  nonlocal called
171
186
  called = True
172
187
 
173
- error = Exception("test error")
174
- await bot.on_error(error)
188
+ await bot._fallback_error_handler(Exception("test error"))
189
+ await bot.on_error(Exception("test error"))
175
190
 
176
- assert called, "Custom error handler was not called"
191
+ assert called, "Fallback error handler was not called"
177
192
 
178
193
 
179
194
  @pytest.mark.asyncio
180
195
  async def test_on_error_logs_when_no_handler(bot):
181
- bot._on_error = None
182
196
  error = Exception("test")
183
197
 
184
198
  await bot.on_error(error)
@@ -197,7 +211,6 @@ async def test_process_commands_executes_command(bot, event):
197
211
  event.body = "!greet"
198
212
  room = MatrixRoom("!roomid:matrix.org", "alias")
199
213
 
200
- # Patch _build_context to return context with command assigned
201
214
  with patch.object(
202
215
  bot, "_build_context", new_callable=AsyncMock
203
216
  ) as mock_build_context:
@@ -383,12 +396,12 @@ async def test_run_uses_token():
383
396
  async def test_run_with_username_and_password(bot):
384
397
  bot.client.login = AsyncMock(return_value="login_resp")
385
398
  bot.client.sync_forever = AsyncMock()
386
- bot.on_ready = AsyncMock()
399
+ bot._on_ready = AsyncMock()
387
400
 
388
401
  await bot.run()
389
402
 
390
403
  bot.client.login.assert_awaited_once_with("grace1234")
391
- bot.on_ready.assert_awaited_once()
404
+ bot._on_ready.assert_awaited_once()
392
405
  bot.client.sync_forever.assert_awaited_once()
393
406
 
394
407
 
@@ -1,6 +1,17 @@
1
1
  import pytest
2
2
 
3
+ from unittest.mock import MagicMock
4
+ from typing import Optional
5
+
3
6
  from matrix.extension import Extension
7
+ from matrix.room import Room
8
+
9
+
10
+ class MockBot:
11
+ prefix: str = "!"
12
+
13
+ def __init__(self, room: Optional[Room] = None) -> None:
14
+ self.get_room = MagicMock(return_value=room or MagicMock(spec=Room))
4
15
 
5
16
 
6
17
  @pytest.fixture
@@ -8,6 +19,14 @@ def extension() -> Extension:
8
19
  return Extension(name="test_ext", prefix="!")
9
20
 
10
21
 
22
+ @pytest.fixture
23
+ def bot() -> MockBot:
24
+ return MockBot()
25
+
26
+
27
+ # INIT
28
+
29
+
11
30
  def test_init_with_name_and_prefix__expect_attributes_set():
12
31
  ext = Extension(name="math", prefix="!")
13
32
 
@@ -21,6 +40,10 @@ def test_init_with_name_only__expect_prefix_is_none():
21
40
  assert ext.prefix is None
22
41
 
23
42
 
43
+ def test_init__expect_bot_is_none(extension: Extension):
44
+ assert extension.bot is None
45
+
46
+
24
47
  def test_init__expect_on_load_is_none(extension: Extension):
25
48
  assert extension._on_load is None
26
49
 
@@ -45,6 +68,9 @@ def test_init__expect_empty_checks(extension: Extension):
45
68
  assert extension._checks == []
46
69
 
47
70
 
71
+ # ON LOAD
72
+
73
+
48
74
  def test_on_load_with_sync_function__expect_handler_registered(extension: Extension):
49
75
  @extension.on_load
50
76
  def setup():
@@ -86,20 +112,34 @@ def test_on_load_overwrites_previous_handler__expect_latest_handler(
86
112
  assert extension._on_load is second
87
113
 
88
114
 
89
- def test_load_with_registered_handler__expect_handler_called(extension: Extension):
115
+ # LOAD
116
+
117
+
118
+ def test_load__expect_bot_set(extension: Extension, bot: MockBot):
119
+ extension.load(bot)
120
+
121
+ assert extension.bot is bot
122
+
123
+
124
+ def test_load_with_registered_handler__expect_handler_called(
125
+ extension: Extension, bot: MockBot
126
+ ):
90
127
  called = []
91
128
 
92
129
  @extension.on_load
93
130
  def setup():
94
131
  called.append(True)
95
132
 
96
- extension.load()
133
+ extension.load(bot)
97
134
 
98
135
  assert called == [True]
99
136
 
100
137
 
101
- def test_load_with_no_handler__expect_no_error(extension: Extension):
102
- extension.load()
138
+ def test_load_with_no_handler__expect_no_error(extension: Extension, bot: MockBot):
139
+ extension.load(bot)
140
+
141
+
142
+ # ON UNLOAD
103
143
 
104
144
 
105
145
  def test_on_unload_with_sync_function__expect_handler_registered(extension: Extension):
@@ -143,17 +183,63 @@ def test_on_unload_overwrites_previous_handler__expect_latest_handler(
143
183
  assert extension._on_unload is second
144
184
 
145
185
 
146
- def test_unload_with_registered_handler__expect_handler_called(extension: Extension):
186
+ # UNLOAD
187
+
188
+
189
+ def test_unload__expect_bot_cleared(extension: Extension, bot: MockBot):
190
+ extension.load(bot)
191
+ extension.unload()
192
+
193
+ assert extension.bot is None
194
+
195
+
196
+ def test_unload_with_registered_handler__expect_handler_called(
197
+ extension: Extension, bot: MockBot
198
+ ):
147
199
  called = []
148
200
 
149
201
  @extension.on_unload
150
202
  def teardown():
151
203
  called.append(True)
152
204
 
205
+ extension.load(bot)
153
206
  extension.unload()
154
207
 
155
208
  assert called == [True]
156
209
 
157
210
 
158
- def test_unload_with_no_handler__expect_no_error(extension: Extension):
211
+ def test_unload_with_no_handler__expect_no_error(extension: Extension, bot: MockBot):
212
+ extension.load(bot)
159
213
  extension.unload()
214
+
215
+
216
+ # GET ROOM
217
+
218
+
219
+ def test_get_room_before_load__expect_runtime_error(extension: Extension):
220
+ with pytest.raises(RuntimeError, match="Extension is not loaded"):
221
+ extension.get_room("!room:example.com")
222
+
223
+
224
+ def test_get_room_after_load__expect_delegates_to_bot(
225
+ extension: Extension, bot: MockBot
226
+ ):
227
+ room_id = "!room:example.com"
228
+ expected_room = MagicMock(spec=Room)
229
+ bot.get_room.return_value = expected_room
230
+
231
+ extension.load(bot)
232
+ result = extension.get_room(room_id)
233
+
234
+ bot.get_room.assert_called_once_with(room_id)
235
+ assert result is expected_room
236
+
237
+
238
+ def test_get_room_after_unload__expect_runtime_error(
239
+ extension: Extension, bot: MockBot
240
+ ):
241
+ extension.load(bot)
242
+ extension.unload()
243
+
244
+ with pytest.raises(RuntimeError, match="Extension is not loaded"):
245
+ extension.get_room("!room:example.com")
@@ -297,12 +297,14 @@ def test_register_error_handler_with_exception_type__expect_handler_in_dict(
297
297
  assert registry._error_handlers[ValueError] is on_value_error
298
298
 
299
299
 
300
- def test_register_generic_error_handler__expect_on_error_set(registry: Registry):
300
+ def test_register_generic_error_handler__expect_fallback_error_handler_set(
301
+ registry: Registry,
302
+ ):
301
303
  @registry.error()
302
304
  async def on_any_error(error):
303
305
  pass
304
306
 
305
- assert registry._on_error is on_any_error
307
+ assert registry._fallback_error_handler is on_any_error
306
308
 
307
309
 
308
310
  def test_register_error_handler_with_non_coroutine__expect_type_error(
@@ -1,34 +0,0 @@
1
- # file generated by setuptools-scm
2
- # don't change, don't track in version control
3
-
4
- __all__ = [
5
- "__version__",
6
- "__version_tuple__",
7
- "version",
8
- "version_tuple",
9
- "__commit_id__",
10
- "commit_id",
11
- ]
12
-
13
- TYPE_CHECKING = False
14
- if TYPE_CHECKING:
15
- from typing import Tuple
16
- from typing import Union
17
-
18
- VERSION_TUPLE = Tuple[Union[int, str], ...]
19
- COMMIT_ID = Union[str, None]
20
- else:
21
- VERSION_TUPLE = object
22
- COMMIT_ID = object
23
-
24
- version: str
25
- __version__: str
26
- __version_tuple__: VERSION_TUPLE
27
- version_tuple: VERSION_TUPLE
28
- commit_id: COMMIT_ID
29
- __commit_id__: COMMIT_ID
30
-
31
- __version__ = version = '1.3.3a0'
32
- __version_tuple__ = version_tuple = (1, 3, 3, 'a0')
33
-
34
- __commit_id__ = commit_id = 'g7d083d049'
File without changes