matrix-python 1.4.5a0__tar.gz → 1.4.7a0__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 (65) hide show
  1. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/PKG-INFO +2 -1
  2. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/_version.py +3 -3
  3. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/bot.py +13 -14
  4. matrix_python-1.4.7a0/matrix/config.py +125 -0
  5. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/registry.py +5 -5
  6. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/PKG-INFO +2 -1
  7. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/SOURCES.txt +0 -2
  8. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/requires.txt +1 -0
  9. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/pyproject.toml +1 -0
  10. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_bot.py +68 -43
  11. matrix_python-1.4.7a0/tests/test_config.py +139 -0
  12. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_registry.py +0 -10
  13. matrix_python-1.4.5a0/matrix/config.py +0 -61
  14. matrix_python-1.4.5a0/tests/config_fixture.yaml +0 -4
  15. matrix_python-1.4.5a0/tests/config_fixture_token.yaml +0 -1
  16. matrix_python-1.4.5a0/tests/test_config.py +0 -81
  17. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/dependabot.yml +0 -0
  18. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/CODEOWNERS +0 -0
  19. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/codeql.yml +0 -0
  20. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/publish.yml +0 -0
  21. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/scorecard.yml +0 -0
  22. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.github/workflows/tests.yml +0 -0
  23. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/.gitignore +0 -0
  24. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/CODE_OF_CONDUCT.md +0 -0
  25. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/CONTRIBUTING.md +0 -0
  26. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/LICENSE +0 -0
  27. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/README.md +0 -0
  28. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/README.md +0 -0
  29. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/checks.py +0 -0
  30. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/config.yaml +0 -0
  31. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/cooldown.py +0 -0
  32. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/error_handling.py +0 -0
  33. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/extension.py +0 -0
  34. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/ping.py +0 -0
  35. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/reaction.py +0 -0
  36. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/examples/scheduler.py +0 -0
  37. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/__init__.py +0 -0
  38. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/checks.py +0 -0
  39. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/command.py +0 -0
  40. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/content.py +0 -0
  41. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/context.py +0 -0
  42. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/errors.py +0 -0
  43. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/extension.py +0 -0
  44. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/group.py +0 -0
  45. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/help/__init__.py +0 -0
  46. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/help/help_command.py +0 -0
  47. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/help/pagination.py +0 -0
  48. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/message.py +0 -0
  49. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/protocols.py +0 -0
  50. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/room.py +0 -0
  51. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/scheduler.py +0 -0
  52. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix/types.py +0 -0
  53. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/dependency_links.txt +0 -0
  54. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/matrix_python.egg-info/top_level.txt +0 -0
  55. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/mypy.ini +0 -0
  56. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/setup.cfg +0 -0
  57. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/help/test_default_help_command.py +0 -0
  58. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/help/test_help_command.py +0 -0
  59. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/help/test_pagination.py +0 -0
  60. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_command.py +0 -0
  61. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_context.py +0 -0
  62. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_extension.py +0 -0
  63. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_group.py +0 -0
  64. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_message.py +0 -0
  65. {matrix_python-1.4.5a0 → matrix_python-1.4.7a0}/tests/test_room.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrix-python
3
- Version: 1.4.5a0
3
+ Version: 1.4.7a0
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>
@@ -689,6 +689,7 @@ Requires-Dist: logger
689
689
  Requires-Dist: PyYAML==6.0.3
690
690
  Requires-Dist: markdown==3.10.2
691
691
  Requires-Dist: APScheduler==3.11.2
692
+ Requires-Dist: envyaml==1.10.211231
692
693
  Provides-Extra: dev
693
694
  Requires-Dist: pytest==9.0.3; extra == "dev"
694
695
  Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.4.5a0'
22
- __version_tuple__ = version_tuple = (1, 4, 5, 'a0')
21
+ __version__ = version = '1.4.7a0'
22
+ __version_tuple__ = version_tuple = (1, 4, 7, 'a0')
23
23
 
24
- __commit_id__ = commit_id = 'g1174e983d'
24
+ __commit_id__ = commit_id = 'gdeb79d4c2'
@@ -184,10 +184,6 @@ class Bot(Registry):
184
184
  await handler(error)
185
185
  return
186
186
 
187
- if self._fallback_error_handler:
188
- await self._fallback_error_handler(error)
189
- return
190
-
191
187
  await self._dispatch("on_error", error)
192
188
 
193
189
  async def on_command(self, _ctx: Context) -> None:
@@ -245,8 +241,7 @@ class Bot(Registry):
245
241
  :func:`asyncio.run`, and ensures the client is closed gracefully
246
242
  on interruption.
247
243
  """
248
- if config is not None:
249
- self._load_config(config)
244
+ self._load_config(config)
250
245
 
251
246
  try:
252
247
  asyncio.run(self.run())
@@ -264,7 +259,7 @@ class Bot(Registry):
264
259
  calls the :meth:`on_ready` hook, and starts the long-running
265
260
  sync loop for receiving events.
266
261
  """
267
- self.client.user = self.config.user_id
262
+ self.client.user = self.config.username
268
263
 
269
264
  self.start_at = time.time()
270
265
  self.log.info("starting – timestamp=%s", self.start_at)
@@ -327,15 +322,19 @@ class Bot(Registry):
327
322
 
328
323
  async def _process_commands(self, room: Room, event: Event) -> None:
329
324
  """Parse and execute commands"""
330
- ctx = await self._build_context(room, event)
325
+ try:
326
+ ctx = await self._build_context(room, event)
331
327
 
332
- if ctx.command:
333
- for check in self._checks:
334
- if not await check(ctx):
335
- raise CheckError(ctx.command, check)
328
+ if ctx.command:
329
+ for check in self._checks:
330
+ if not await check(ctx):
331
+ raise CheckError(ctx.command, check)
336
332
 
337
- await self._on_command(ctx)
338
- await ctx.command(ctx)
333
+ await self._on_command(ctx)
334
+ await ctx.command(ctx)
335
+ except Exception as error:
336
+ ctx = Context(bot=self, room=room, event=event)
337
+ await self._on_command_error(ctx, error)
339
338
 
340
339
  async def _build_context(self, matrix_room: Room, event: Event) -> Context:
341
340
  room = self.get_room(matrix_room.room_id)
@@ -0,0 +1,125 @@
1
+ from typing import Any
2
+
3
+ from envyaml import EnvYAML
4
+
5
+ from .errors import ConfigError
6
+
7
+
8
+ class Config:
9
+ """Configuration handler for Matrix client settings.
10
+
11
+ Manages all settings required to connect and authenticate with a Matrix
12
+ homeserver. Configuration can be loaded from a YAML file or provided
13
+ directly via constructor parameters. At least one authentication method
14
+ must be provided.
15
+
16
+ # Example
17
+
18
+ ```python
19
+ # Load from file
20
+ config = Config(config_path="path/to/config..yaml")
21
+
22
+ # Manual configuration
23
+ config = Config(username="@bot:matrix.org", password="secret")
24
+ ```
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ config_path: str | None = None,
30
+ *,
31
+ homeserver: str | None = None,
32
+ username: str | None = None,
33
+ password: str | None = None,
34
+ token: str | None = None,
35
+ prefix: str | None = None,
36
+ ) -> None:
37
+ """Initialize the bot configuration.
38
+
39
+ Loads configuration from a YAML file if provided, otherwise uses
40
+ the provided parameters directly. At least one of password or token
41
+ must be supplied.
42
+
43
+ # Example
44
+
45
+ ```python
46
+ config = Config(
47
+ username="@bot:matrix.org",
48
+ password="secret",
49
+ prefix="!",
50
+ )
51
+ ```
52
+ """
53
+ self._data: dict[str, Any] = {}
54
+
55
+ self.homeserver: str = homeserver or "https://matrix.org"
56
+ self.username: str | None = username
57
+ self.password: str | None = password
58
+ self.token: str | None = token
59
+ self.prefix: str = prefix or "!"
60
+
61
+ if config_path:
62
+ self.load_from_file(config_path)
63
+ else:
64
+ if not self.password and not self.token:
65
+ raise ConfigError("username and password or token")
66
+
67
+ self._data = {
68
+ "HOMESERVER": self.homeserver,
69
+ "USERNAME": self.username,
70
+ "PASSWORD": self.password,
71
+ "TOKEN": self.token,
72
+ "PREFIX": self.prefix,
73
+ }
74
+
75
+ def load_from_file(self, config_path: str) -> None:
76
+ """Load Matrix client settings from a YAML config file.
77
+
78
+ Supports environment variable substitution via EnvYAML. Values in
79
+ the YAML file can reference environment variables using ${VAR} syntax.
80
+
81
+ # Example
82
+
83
+ ```python
84
+ config = Config()
85
+ config.load_from_file("path/to/config.yaml")
86
+ ```
87
+ """
88
+ self._data = dict(EnvYAML(config_path))
89
+
90
+ password = self._data.get("PASSWORD", None)
91
+ token = self._data.get("TOKEN", None)
92
+
93
+ if not password and not token:
94
+ raise ConfigError("USERNAME and PASSWORD or TOKEN")
95
+
96
+ self.homeserver = self._data.get("HOMESERVER", "https://matrix.org")
97
+ self.username = self._data.get("USERNAME")
98
+ self.password = password
99
+ self.token = token
100
+ self.prefix = self._data.get("PREFIX", "!")
101
+
102
+ def get(self, key: str, *, section: str | None = None, default: Any = None) -> Any:
103
+ """Access a config value by key, optionally scoped to a section.
104
+
105
+ # Example
106
+
107
+ ```python
108
+ config.get(key="main_channel", section="bot")
109
+ config.get(key="log_level", default="INFO")
110
+ ```
111
+ """
112
+ if section in self._data:
113
+ return self._data.get(section, {}).get(key, default)
114
+ return self._data.get(key, default)
115
+
116
+ def __getitem__(self, key: str) -> Any:
117
+ """Access a config value by key, raising KeyError if not found.
118
+
119
+ # Example
120
+
121
+ ```python
122
+ config["bot"]["main_channel"]
123
+ ```
124
+ """
125
+ return self._data[key]
@@ -65,7 +65,6 @@ class Registry:
65
65
 
66
66
  self._event_handlers: Dict[Type[Event], List[Callback]] = defaultdict(list)
67
67
  self._hook_handlers: Dict[str, List[Callback]] = defaultdict(list)
68
- self._fallback_error_handler: Optional[ErrorCallback] = None
69
68
  self._error_handlers: Dict[type[Exception], ErrorCallback] = {}
70
69
  self._command_error_handlers: Dict[type[Exception], CommandErrorCallback] = {}
71
70
 
@@ -378,14 +377,15 @@ class Registry:
378
377
  ```
379
378
  """
380
379
 
380
+ if not exception:
381
+ exception = Exception
382
+
381
383
  def wrapper(func: ErrorCallback) -> ErrorCallback:
382
384
  if not inspect.iscoroutinefunction(func):
383
385
  raise TypeError("Error handlers must be coroutines")
384
386
 
385
- if exception:
386
- self._error_handlers[exception] = func
387
- else:
388
- self._fallback_error_handler = func
387
+ self._error_handlers[exception] = func
388
+
389
389
  logger.debug(
390
390
  "registered error handler '%s' on %s",
391
391
  func.__name__,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrix-python
3
- Version: 1.4.5a0
3
+ Version: 1.4.7a0
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>
@@ -689,6 +689,7 @@ Requires-Dist: logger
689
689
  Requires-Dist: PyYAML==6.0.3
690
690
  Requires-Dist: markdown==3.10.2
691
691
  Requires-Dist: APScheduler==3.11.2
692
+ Requires-Dist: envyaml==1.10.211231
692
693
  Provides-Extra: dev
693
694
  Requires-Dist: pytest==9.0.3; extra == "dev"
694
695
  Requires-Dist: pytest-asyncio==1.3.0; extra == "dev"
@@ -45,8 +45,6 @@ matrix_python.egg-info/SOURCES.txt
45
45
  matrix_python.egg-info/dependency_links.txt
46
46
  matrix_python.egg-info/requires.txt
47
47
  matrix_python.egg-info/top_level.txt
48
- tests/config_fixture.yaml
49
- tests/config_fixture_token.yaml
50
48
  tests/test_bot.py
51
49
  tests/test_command.py
52
50
  tests/test_config.py
@@ -3,6 +3,7 @@ logger
3
3
  PyYAML==6.0.3
4
4
  markdown==3.10.2
5
5
  APScheduler==3.11.2
6
+ envyaml==1.10.211231
6
7
 
7
8
  [dev]
8
9
  pytest==9.0.3
@@ -22,6 +22,7 @@ dependencies = [
22
22
  "PyYAML==6.0.3",
23
23
  "markdown==3.10.2",
24
24
  "APScheduler==3.11.2",
25
+ "envyaml==1.10.211231",
25
26
  ]
26
27
 
27
28
  [project.optional-dependencies]
@@ -3,9 +3,7 @@ import pytest
3
3
  from unittest.mock import AsyncMock, MagicMock, patch
4
4
  from nio import MatrixRoom, RoomMessageText
5
5
 
6
- from matrix.bot import Bot
7
- from matrix.config import Config
8
- from matrix.extension import Extension
6
+ from matrix.bot import Bot, Config, Extension, Room
9
7
  from matrix.errors import (
10
8
  CheckError,
11
9
  CommandNotFoundError,
@@ -16,7 +14,30 @@ from matrix.errors import (
16
14
  @pytest.fixture
17
15
  def bot():
18
16
  b = Bot()
19
- b._load_config("tests/config_fixture.yaml")
17
+ b._load_config(
18
+ Config(
19
+ username="grace",
20
+ password="grace1234",
21
+ )
22
+ )
23
+
24
+ b._client = MagicMock()
25
+ b._client.room_send = AsyncMock()
26
+ b.log = MagicMock()
27
+ b.log.getChild.return_value = MagicMock()
28
+
29
+ return b
30
+
31
+
32
+ @pytest.fixture
33
+ def bot_with_token():
34
+ b = Bot()
35
+ b._load_config(
36
+ Config(
37
+ username="grace",
38
+ token="abc123",
39
+ )
40
+ )
20
41
 
21
42
  b._client = MagicMock()
22
43
  b._client.room_send = AsyncMock()
@@ -50,7 +71,7 @@ def test_bot_init_with_config():
50
71
  bot = Bot()
51
72
  bot._load_config(Config(username="grace", password="grace1234"))
52
73
 
53
- assert bot.config.user_id == "grace"
74
+ assert bot.config.username == "grace"
54
75
  assert bot.config.password == "grace1234"
55
76
  assert bot.config.homeserver == "https://matrix.org"
56
77
 
@@ -95,7 +116,8 @@ async def test_dispatch_calls_all_handlers(bot):
95
116
  "type": "m.room.message",
96
117
  }
97
118
  )
98
- room = MatrixRoom("!roomid:matrix.org", "room_alias")
119
+ matrix_room = MatrixRoom("!roomid:matrix.org", "room_alias")
120
+ room = Room(matrix_room, bot.client)
99
121
 
100
122
  await bot._dispatch_matrix_event(room, event)
101
123
  assert "h1" in called
@@ -183,9 +205,7 @@ async def test_on_error_calls_fallback_handler(bot):
183
205
  nonlocal called
184
206
  called = True
185
207
 
186
- await bot._fallback_error_handler(Exception("test error"))
187
- await bot.on_error(Exception("test error"))
188
-
208
+ await bot._on_error(Exception("test error"))
189
209
  assert called, "Fallback error handler was not called"
190
210
 
191
211
 
@@ -222,7 +242,9 @@ async def test_process_commands_executes_command(bot, event):
222
242
 
223
243
 
224
244
  @pytest.mark.asyncio
225
- async def test_command_not_found_raises(bot):
245
+ async def test_command_not_found_calls_command_error_handler(bot):
246
+ bot._on_command_error = AsyncMock()
247
+
226
248
  event = RoomMessageText.from_dict(
227
249
  {
228
250
  "content": {"body": "!nonexistent", "msgtype": "m.text"},
@@ -235,17 +257,14 @@ async def test_command_not_found_raises(bot):
235
257
 
236
258
  room = MatrixRoom("!roomid", "alias")
237
259
 
238
- with patch("matrix.context.Context", autospec=True) as MockContext:
239
- mock_ctx = MagicMock()
240
- mock_ctx.body = "!nonexistent"
241
- MockContext.return_value = mock_ctx
260
+ await bot._process_commands(room, event)
242
261
 
243
- with pytest.raises(CommandNotFoundError):
244
- await bot._process_commands(room, event)
262
+ bot._on_command_error.assert_awaited_once()
263
+ assert isinstance(bot._on_command_error.call_args[0][1], CommandNotFoundError)
245
264
 
246
265
 
247
266
  @pytest.mark.asyncio
248
- async def test_bot_does_not_execute_when_global_check_fails(bot, event):
267
+ async def test_bot_does_not_execute_command_when_global_check_fails(bot):
249
268
  called = False
250
269
 
251
270
  @bot.command()
@@ -257,6 +276,8 @@ async def test_bot_does_not_execute_when_global_check_fails(bot, event):
257
276
  async def global_check(ctx):
258
277
  return False
259
278
 
279
+ bot._on_command_error = AsyncMock()
280
+
260
281
  event = RoomMessageText.from_dict(
261
282
  {
262
283
  "content": {"body": "!greet", "msgtype": "m.text"},
@@ -267,18 +288,14 @@ async def test_bot_does_not_execute_when_global_check_fails(bot, event):
267
288
  }
268
289
  )
269
290
 
270
- room = MatrixRoom("!roomid", "alias")
271
-
272
- with patch("matrix.context.Context", autospec=True) as MockContext:
273
- mock_ctx = MagicMock()
274
- mock_ctx.body = "!greet"
275
- mock_ctx.command = bot.commands["greet"]
276
- MockContext.return_value = mock_ctx
291
+ matrix_room = MatrixRoom("!roomid", "alias")
292
+ room = Room(matrix_room, bot.client)
277
293
 
278
- with pytest.raises(CheckError):
279
- await bot._process_commands(room, event)
294
+ await bot._process_commands(room, event)
280
295
 
281
- assert not called, "Expected command handler not to be called"
296
+ assert not called
297
+ bot._on_command_error.assert_awaited_once()
298
+ assert isinstance(bot._on_command_error.call_args[0][1], CheckError)
282
299
 
283
300
 
284
301
  @pytest.mark.asyncio
@@ -411,17 +428,14 @@ async def start_and_stop(coro):
411
428
 
412
429
 
413
430
  @pytest.mark.asyncio
414
- async def test_run_uses_token():
415
- bot = Bot()
416
- bot._load_config("tests/config_fixture_token.yaml")
417
-
418
- bot._client.sync_forever = AsyncMock()
419
- bot._on_ready = AsyncMock()
431
+ async def test_run_uses_token(bot_with_token):
432
+ bot_with_token._client.sync_forever = AsyncMock()
433
+ bot_with_token._on_ready = AsyncMock()
420
434
 
421
435
  # unblock readiness
422
- bot._synced.set()
436
+ bot_with_token._synced.set()
423
437
 
424
- task = asyncio.create_task(bot.run())
438
+ task = asyncio.create_task(bot_with_token.run())
425
439
 
426
440
  await asyncio.sleep(0)
427
441
  await asyncio.sleep(0)
@@ -429,14 +443,22 @@ async def test_run_uses_token():
429
443
  task.cancel()
430
444
  await asyncio.gather(task, return_exceptions=True)
431
445
 
432
- assert bot._client.access_token == "abc123"
433
- bot._on_ready.assert_awaited_once()
434
- bot._client.sync_forever.assert_awaited_once()
446
+ assert bot_with_token._client.access_token == "abc123"
447
+ bot_with_token._on_ready.assert_awaited_once()
448
+ bot_with_token._client.sync_forever.assert_awaited_once()
435
449
 
436
450
 
437
451
  @pytest.mark.asyncio
438
452
  async def test_run_with_username_and_password(bot):
439
- bot._client.login = AsyncMock(return_value="login_resp")
453
+ assert bot.config.token is None
454
+
455
+ login_called = asyncio.Event()
456
+
457
+ async def mock_login(password):
458
+ login_called.set()
459
+ return "login_resp"
460
+
461
+ bot._client.login = AsyncMock(side_effect=mock_login)
440
462
  bot._client.sync_forever = AsyncMock()
441
463
  bot._on_ready = AsyncMock()
442
464
 
@@ -444,15 +466,13 @@ async def test_run_with_username_and_password(bot):
444
466
 
445
467
  task = asyncio.create_task(bot.run())
446
468
 
447
- await asyncio.sleep(0)
448
- await asyncio.sleep(0)
469
+ await asyncio.wait_for(login_called.wait(), timeout=1.0)
449
470
 
450
471
  task.cancel()
451
472
  await asyncio.gather(task, return_exceptions=True)
452
473
 
453
474
  bot._client.login.assert_awaited_once_with("grace1234")
454
475
  bot._on_ready.assert_awaited_once()
455
- bot._client.sync_forever.assert_awaited_once()
456
476
 
457
477
 
458
478
  def test_start_handles_keyboard_interrupt(caplog):
@@ -463,7 +483,12 @@ def test_start_handles_keyboard_interrupt(caplog):
463
483
 
464
484
  with patch.object(bot, "_load_config"):
465
485
  with caplog.at_level("INFO"):
466
- bot.start(config="tests/config_fixture.yaml")
486
+ bot.start(
487
+ config=Config(
488
+ username="grace",
489
+ password="grace1234",
490
+ )
491
+ )
467
492
 
468
493
  assert "bot interrupted by user" in caplog.text
469
494
  bot._client.close.assert_awaited_once()
@@ -0,0 +1,139 @@
1
+ import os
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+ import yaml
6
+
7
+ from matrix.config import Config
8
+ from matrix.errors import ConfigError
9
+
10
+
11
+ @pytest.fixture
12
+ def config_default():
13
+ return Config(username="grace", password="secret")
14
+
15
+
16
+ @pytest.fixture
17
+ def config_file(tmp_path):
18
+ config = tmp_path / "test.yaml"
19
+ config.write_text(
20
+ "USERNAME: '@bot:matrix.org'\n"
21
+ "PASSWORD: 'secret'\n"
22
+ "PREFIX: '!'\n"
23
+ "LOG_LEVEL: 'INFO'\n"
24
+ "bot:\n"
25
+ " main_channel: '!abc123:matrix.org'\n"
26
+ )
27
+ return config
28
+
29
+
30
+ @pytest.fixture
31
+ def config(config_file):
32
+ return Config(config_path=str(config_file))
33
+
34
+
35
+ def test_get__returns_top_level_value(config: Config) -> None:
36
+ assert config.get(key="LOG_LEVEL") == "INFO"
37
+
38
+
39
+ def test_get__returns_none_when_key_missing_and_no_default(config: Config) -> None:
40
+ assert config.get(key="MISSING") is None
41
+
42
+
43
+ def test_get__returns_default_when_key_missing(config: Config) -> None:
44
+ assert config.get(key="MISSING", default="fallback") == "fallback"
45
+
46
+
47
+ def test_get__returns_section_value(config: Config) -> None:
48
+ assert config.get(key="main_channel", section="bot") == "!abc123:matrix.org"
49
+
50
+
51
+ def test_get__returns_default_when_section_missing(config: Config) -> None:
52
+ assert (
53
+ config.get(key="main_channel", section="MISSING", default="fallback")
54
+ == "fallback"
55
+ )
56
+
57
+
58
+ def test_get__returns_default_when_section_key_missing(config: Config) -> None:
59
+ assert config.get(key="MISSING", section="bot", default="fallback") == "fallback"
60
+
61
+
62
+ def test_getitem__returns_value(config: Config) -> None:
63
+ assert config["LOG_LEVEL"] == "INFO"
64
+
65
+
66
+ def test_getitem__raises_key_error_when_missing(config: Config) -> None:
67
+ with pytest.raises(KeyError):
68
+ _ = config["MISSING"]
69
+
70
+
71
+ @pytest.mark.parametrize(
72
+ "attr,expected",
73
+ [
74
+ ("homeserver", "https://matrix.org"),
75
+ ("username", "grace"),
76
+ ("password", "secret"),
77
+ ("token", None),
78
+ ("prefix", "!"),
79
+ ],
80
+ )
81
+ def test_config_defaults_success(config_default, attr, expected):
82
+ assert getattr(config_default, attr) == expected
83
+
84
+
85
+ def test_loading_valid_yaml(tmp_path):
86
+ yaml_text = """
87
+ HOMESERVER: https://matrix.org
88
+ USERNAME: "@grace:matrix.org"
89
+ PASSWORD: grace1234
90
+ PREFIX: "/"
91
+ """
92
+ config_file = tmp_path / "good.yaml"
93
+ config_file.write_text(yaml_text)
94
+
95
+ cfg = Config(str(config_file))
96
+
97
+ assert cfg.username == "@grace:matrix.org"
98
+ assert cfg.password == "grace1234"
99
+ assert cfg.prefix == "/"
100
+
101
+
102
+ def test_file_not_found(tmp_path):
103
+ with pytest.raises(FileNotFoundError):
104
+ Config(str(tmp_path / "nope.yaml"))
105
+
106
+
107
+ def test_bad_yaml_syntax(tmp_path):
108
+ bad = tmp_path / "bad.yaml"
109
+ bad.write_text("not: valid: : yaml")
110
+ with pytest.raises(yaml.YAMLError):
111
+ Config(str(bad))
112
+
113
+
114
+ def test_missing_credentials_raises_ConfigError_kwargs():
115
+ with pytest.raises(ConfigError) as exc:
116
+ Config(username="only_user")
117
+ # the assert make sure that the error is raised from
118
+ # the constructor and not load_from_file method
119
+ assert "username and password or token" in str(exc.value)
120
+
121
+
122
+ def test_missing_credentials_raises_ConfigError_yaml(tmp_path):
123
+ yaml_text = "HOMESERVER: https://matrix.org\n" "PASSWORD: \n" "TOKEN: \n"
124
+ file = tmp_path / "err.yaml"
125
+ file.write_text(yaml_text)
126
+
127
+ with patch.dict(os.environ, {}, clear=True):
128
+ with pytest.raises(ConfigError) as exc:
129
+ Config(config_path=str(file))
130
+ assert "USERNAME and PASSWORD or TOKEN" in str(exc.value)
131
+
132
+
133
+ def test_token_only():
134
+ token = "my_very_secure_token"
135
+ cfg = Config(token=token)
136
+
137
+ assert cfg.token == token
138
+ assert cfg.password is None
139
+ assert cfg.homeserver == "https://matrix.org"
@@ -297,16 +297,6 @@ 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_fallback_error_handler_set(
301
- registry: Registry,
302
- ):
303
- @registry.error()
304
- async def on_any_error(error):
305
- pass
306
-
307
- assert registry._fallback_error_handler is on_any_error
308
-
309
-
310
300
  def test_register_error_handler_with_non_coroutine__expect_type_error(
311
301
  registry: Registry,
312
302
  ):
@@ -1,61 +0,0 @@
1
- import yaml
2
- from .errors import ConfigError
3
- from typing import Optional
4
-
5
-
6
- class Config:
7
- """
8
- Configuration handler for Matrix client settings. Including the following:
9
-
10
- homeserver: Defaults to 'https://matrix.org'
11
- user_id: The Matrix user ID (username).
12
- password: (Optional) One of the password or token must be provided.
13
- token: (Optional) One of the password or token must be provided.
14
- prefix: Defaults to '!' if not specified in the config file.
15
-
16
- :param config_path: Path to the YAML configuration file.
17
- :param homeserver: The Matrix homeserver URL.
18
- :param username: The Matrix user ID (username).
19
- :param password: The password for the Matrix user.
20
- :param token: The access token for the Matrix user.
21
- :param prefix: The command prefix.
22
-
23
- :raises FileNotFoundError: If the configuration file does not exist.
24
- :raises yaml.YAMLError: If the configuration file cannot be parsed.
25
- :raises ConfigError: If neither password or token has been provided.
26
- """
27
-
28
- def __init__(
29
- self,
30
- config_path: Optional[str] = None,
31
- *,
32
- homeserver: Optional[str] = None,
33
- username: Optional[str] = None,
34
- password: Optional[str] = None,
35
- token: Optional[str] = None,
36
- prefix: Optional[str] = None,
37
- ) -> None:
38
- self.homeserver: str = homeserver or "https://matrix.org"
39
- self.user_id: Optional[str] = username
40
- self.password: Optional[str] = password
41
- self.token: Optional[str] = token
42
- self.prefix: str = prefix or "!"
43
-
44
- if config_path:
45
- self.load_from_file(config_path)
46
- elif not (self.password or self.token):
47
- raise ConfigError("username and password or token")
48
-
49
- def load_from_file(self, config_path: str) -> None:
50
- """Load Matrix client settings via YAML config file."""
51
- with open(config_path, "r") as f:
52
- config = yaml.safe_load(f)
53
-
54
- if not (config.get("PASSWORD") or config.get("TOKEN")):
55
- raise ConfigError("USERNAME and PASSWORD or TOKEN")
56
-
57
- self.homeserver = config.get("HOMESERVER", "https://matrix.org")
58
- self.user_id = config.get("USERNAME")
59
- self.password = config.get("PASSWORD", None)
60
- self.token = config.get("TOKEN", None)
61
- self.prefix = config.get("PREFIX", "!")
@@ -1,4 +0,0 @@
1
- HOMESERVER: "matrix.fixture.org"
2
- USERNAME: "grace"
3
- PASSWORD: "grace1234"
4
- PREFIX: "!"
@@ -1 +0,0 @@
1
- TOKEN: "abc123"
@@ -1,81 +0,0 @@
1
- import pytest
2
- import yaml
3
-
4
- from matrix.errors import ConfigError
5
- from matrix.config import Config
6
-
7
-
8
- @pytest.fixture
9
- def config_default():
10
- return Config(username="grace", password="secret")
11
-
12
-
13
- @pytest.mark.parametrize(
14
- "attr,expected",
15
- [
16
- ("homeserver", "https://matrix.org"),
17
- ("user_id", "grace"),
18
- ("password", "secret"),
19
- ("token", None),
20
- ("prefix", "!"),
21
- ],
22
- )
23
- def test_config_defaults_success(config_default, attr, expected):
24
- assert getattr(config_default, attr) == expected
25
-
26
-
27
- def test_loading_valid_yaml(tmp_path):
28
- yaml_text = """
29
- HOMESERVER: https://matrix.org
30
- USERNAME: "@grace:matrix.org"
31
- PASSWORD: grace1234
32
- PREFIX: "/"
33
- """
34
- config_file = tmp_path / "good.yaml"
35
- config_file.write_text(yaml_text)
36
-
37
- cfg = Config(str(config_file))
38
-
39
- assert cfg.user_id == "@grace:matrix.org"
40
- assert cfg.password == "grace1234"
41
- assert cfg.prefix == "/"
42
-
43
-
44
- def test_file_not_found(tmp_path):
45
- with pytest.raises(FileNotFoundError):
46
- Config(str(tmp_path / "nope.yaml"))
47
-
48
-
49
- def test_bad_yaml_syntax(tmp_path):
50
- bad = tmp_path / "bad.yaml"
51
- bad.write_text("not: valid: : yaml")
52
- with pytest.raises(yaml.YAMLError):
53
- Config(str(bad))
54
-
55
-
56
- def test_missing_credentials_raises_ConfigError_kwargs():
57
- with pytest.raises(ConfigError) as exc:
58
- Config(username="only_user")
59
- # the assert make sure that the error is raised from
60
- # the constructor and not load_from_file method
61
- assert "username and password or token" in str(exc.value)
62
-
63
-
64
- def test_missing_credentials_raises_ConfigError_yaml(tmp_path):
65
- yaml_text = "HOMESERVER: https://matrix.org"
66
- file = tmp_path / "err.yaml"
67
- file.write_text(yaml_text)
68
- with pytest.raises(ConfigError) as exc:
69
- Config(str(file))
70
- # the assert make sure that the error is raised from
71
- # the load_from_file method and not the constructor
72
- assert "USERNAME and PASSWORD or TOKEN" in str(exc.value)
73
-
74
-
75
- def test_token_only():
76
- token = "my_very_secure_token"
77
- cfg = Config(token=token)
78
-
79
- assert cfg.token == token
80
- assert cfg.password is None
81
- assert cfg.homeserver == "https://matrix.org"
File without changes