From a65bfb95bf0f103fe34d0d913159e6aadd42b2d4 Mon Sep 17 00:00:00 2001 From: Simon Roy Date: Tue, 16 Jun 2026 01:21:06 -0400 Subject: [PATCH 1/6] Add get_spaces, get_space, and get_rooms --- matrix/bot.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ matrix/space.py | 5 ++++ 2 files changed, 68 insertions(+) create mode 100644 matrix/space.py diff --git a/matrix/bot.py b/matrix/bot.py index 51d876a..f5516f2 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -8,6 +8,7 @@ from nio import AsyncClient, Event, MatrixRoom from .room import Room +from .space import Space from .group import Group from .config import Config from .context import Context @@ -92,6 +93,7 @@ def get_room(self, room_id: str) -> Room | None: ```python room = bot.get_room("!abc123:matrix.org") + if room: print(room.name) ``` @@ -100,6 +102,67 @@ def get_room(self, room_id: str) -> Room | None: return Room(matrix_room=matrix_room, client=self.client) return None + def get_rooms(self) -> list[Room]: + """Retrieve a list of all rooms the bot is aware of. + + This method returns a list of `Room` objects for all rooms currently + known to the client. This includes both regular rooms and spaces. + + ## Example + + ```python + rooms = bot.get_rooms() + + for room in rooms: + print(room.name) + ``` + """ + return [ + Room(matrix_room=matrix_room, client=self.client) + for matrix_room in self.client.rooms.values() + ] + + def get_space(self, space_id: str) -> Space | None: + """Retrieve a `Space` instance by its Matrix room ID. + + Returns the `Space` object corresponding to `space_id` if it exists in + the client's known rooms and is a space. Returns `None` otherwise. + + ## Example + + ```python + space = bot.get_space("!space123:matrix.org") + + if space: + print(space.name) + ``` + """ + if ( + matrix_room := self.client.rooms.get(space_id) + ) and matrix_room.room_type == "m.space": + return Space(matrix_room=matrix_room, client=self.client) + return None + + def get_spaces(self) -> list[Space]: + """Retrieve a list of all spaces the bot is aware of. + + This method returns a list of `Space` objects for all rooms currently + known to the client that are identified as spaces. + + ## Example + + ```python + spaces = bot.get_spaces() + for space in spaces: + print(space.name) + ``` + """ + return [ + Space(matrix_room=matrix_room, client=self.client) + for matrix_room in self.client.rooms.values() + if matrix_room.room_type == "m.space" + ] + def load_extension(self, extension: Extension) -> None: self.log.debug(f"Loading extension: '{extension.name}'") diff --git a/matrix/space.py b/matrix/space.py new file mode 100644 index 0000000..b22d28a --- /dev/null +++ b/matrix/space.py @@ -0,0 +1,5 @@ +from .room import Room + + +class Space(Room): + pass From 83acc8d8d07b9ea75e29af23e648c45c76f5179a Mon Sep 17 00:00:00 2001 From: Simon Roy Date: Tue, 16 Jun 2026 01:33:57 -0400 Subject: [PATCH 2/6] add tests for get_room, get_rooms, get_space and get_spaces --- tests/test_bot.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/test_bot.py b/tests/test_bot.py index 39e19eb..66d69e1 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from nio import MatrixRoom, RoomMessageText -from matrix.bot import Bot, Config, Extension, Room +from matrix.bot import Bot, Config, Extension, Room, Space from matrix.errors import ( CheckError, CommandNotFoundError, @@ -67,6 +67,76 @@ def event(): ) +@pytest.fixture +def space_room(): + space = MatrixRoom(room_id="!space:id", own_user_id="grace") + space.name = "Test Space" + space.room_type = "m.space" + return space + + +def test_get_room__with_known_room_id__expect_room(bot, room): + bot._client.rooms = {room.room_id: room} + + result = bot.get_room(room.room_id) + + assert isinstance(result, Room) + assert result.matrix_room is room + + +def test_get_room__with_unknown_room_id__expect_none(bot): + bot._client.rooms = {} + + assert bot.get_room("!missing:id") is None + + +def test_get_rooms__expect_all_known_rooms(bot, room, space_room): + bot._client.rooms = {room.room_id: room, space_room.room_id: space_room} + + rooms = bot.get_rooms() + + assert len(rooms) == 2 + assert {r.matrix_room for r in rooms} == {room, space_room} + assert all(isinstance(r, Room) for r in rooms) + + +def test_get_space__with_space_room_id__expect_space(bot, space_room): + bot._client.rooms = {space_room.room_id: space_room} + + result = bot.get_space(space_room.room_id) + + assert isinstance(result, Space) + assert result.matrix_room is space_room + + +def test_get_space__with_non_space_room_id__expect_none(bot, room): + bot._client.rooms = {room.room_id: room} + + assert bot.get_space(room.room_id) is None + + +def test_get_space__with_unknown_room_id__expect_none(bot): + bot._client.rooms = {} + + assert bot.get_space("!missing:id") is None + + +def test_get_spaces__with_mixed_rooms__expect_only_spaces(bot, room, space_room): + bot._client.rooms = {room.room_id: room, space_room.room_id: space_room} + + spaces = bot.get_spaces() + + assert len(spaces) == 1 + assert isinstance(spaces[0], Space) + assert spaces[0].matrix_room is space_room + + +def test_get_spaces__with_no_spaces__expect_empty_list(bot, room): + bot._client.rooms = {room.room_id: room} + + assert bot.get_spaces() == [] + + def test_bot_init_with_config(): bot = Bot() bot._load_config(Config(username="grace", password="grace1234")) From 24d110d2eb4ec4bad7daf9a8c08597324a3eaa19 Mon Sep 17 00:00:00 2001 From: Simon Roy Date: Tue, 16 Jun 2026 01:42:56 -0400 Subject: [PATCH 3/6] Made get_room and get_rooms return Room or Space depending on the room_type --- matrix/bot.py | 32 ++++++++++++++++---------------- tests/test_bot.py | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/matrix/bot.py b/matrix/bot.py index f5516f2..edf9a2b 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -88,6 +88,7 @@ def get_room(self, room_id: str) -> Room | None: Returns the `Room` object corresponding to `room_id` if it exists in the client's known rooms. Returns `None` if the room cannot be found. + If the room is a space, a `Space` instance is returned instead. ## Example @@ -99,14 +100,16 @@ def get_room(self, room_id: str) -> Room | None: ``` """ if matrix_room := self.client.rooms.get(room_id): - return Room(matrix_room=matrix_room, client=self.client) + room_cls = Space if matrix_room.room_type == "m.space" else Room + return room_cls(matrix_room=matrix_room, client=self.client) return None def get_rooms(self) -> list[Room]: """Retrieve a list of all rooms the bot is aware of. This method returns a list of `Room` objects for all rooms currently - known to the client. This includes both regular rooms and spaces. + known to the client. This includes both regular rooms and spaces; + spaces are returned as `Space` instances. ## Example @@ -117,10 +120,13 @@ def get_rooms(self) -> list[Room]: print(room.name) ``` """ - return [ - Room(matrix_room=matrix_room, client=self.client) - for matrix_room in self.client.rooms.values() - ] + rooms = [] + + for matrix_room in self.client.rooms.values(): + room_cls = Space if matrix_room.room_type == "m.space" else Room + rooms.append(room_cls(matrix_room=matrix_room, client=self.client)) + + return rooms def get_space(self, space_id: str) -> Space | None: """Retrieve a `Space` instance by its Matrix room ID. @@ -137,11 +143,8 @@ def get_space(self, space_id: str) -> Space | None: print(space.name) ``` """ - if ( - matrix_room := self.client.rooms.get(space_id) - ) and matrix_room.room_type == "m.space": - return Space(matrix_room=matrix_room, client=self.client) - return None + room = self.get_room(space_id) + return room if isinstance(room, Space) else None def get_spaces(self) -> list[Space]: """Retrieve a list of all spaces the bot is aware of. @@ -153,15 +156,12 @@ def get_spaces(self) -> list[Space]: ```python spaces = bot.get_spaces() + for space in spaces: print(space.name) ``` """ - return [ - Space(matrix_room=matrix_room, client=self.client) - for matrix_room in self.client.rooms.values() - if matrix_room.room_type == "m.space" - ] + return [room for room in self.get_rooms() if isinstance(room, Space)] def load_extension(self, extension: Extension) -> None: self.log.debug(f"Loading extension: '{extension.name}'") diff --git a/tests/test_bot.py b/tests/test_bot.py index 66d69e1..bf0b8e6 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -80,10 +80,19 @@ def test_get_room__with_known_room_id__expect_room(bot, room): result = bot.get_room(room.room_id) - assert isinstance(result, Room) + assert type(result) is Room assert result.matrix_room is room +def test_get_room__with_space_room_id__expect_space(bot, space_room): + bot._client.rooms = {space_room.room_id: space_room} + + result = bot.get_room(space_room.room_id) + + assert isinstance(result, Space) + assert result.matrix_room is space_room + + def test_get_room__with_unknown_room_id__expect_none(bot): bot._client.rooms = {} @@ -94,10 +103,11 @@ def test_get_rooms__expect_all_known_rooms(bot, room, space_room): bot._client.rooms = {room.room_id: room, space_room.room_id: space_room} rooms = bot.get_rooms() + by_id = {r.room_id: r for r in rooms} assert len(rooms) == 2 - assert {r.matrix_room for r in rooms} == {room, space_room} - assert all(isinstance(r, Room) for r in rooms) + assert type(by_id[room.room_id]) is Room + assert isinstance(by_id[space_room.room_id], Space) def test_get_space__with_space_room_id__expect_space(bot, space_room): From 3a95a2bd582a522443fa3693af14ea0a3bf19d5b Mon Sep 17 00:00:00 2001 From: penguinboi Date: Tue, 16 Jun 2026 21:24:01 -0400 Subject: [PATCH 4/6] Added doc --- docs/docs/reference/space.md | 15 +++++++++++++++ docs/mkdocs.yml | 1 + 2 files changed, 16 insertions(+) create mode 100644 docs/docs/reference/space.md diff --git a/docs/docs/reference/space.md b/docs/docs/reference/space.md new file mode 100644 index 0000000..37920be --- /dev/null +++ b/docs/docs/reference/space.md @@ -0,0 +1,15 @@ +# Space + +`Space` extends `Room` to represent a Matrix Space. It is returned by `Bot.get_space()` and `Bot.get_spaces()` instead of a plain `Room` whenever the room type is `m.space`. + +```python +from matrix import Bot + +bot = Bot() + +space = bot.get_space("!space123:matrix.org") +if space: + print(space.name) +``` + +::: matrix.space.Space diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e3efa30..ee15897 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -109,6 +109,7 @@ nav: - Protocols: reference/protocols.md - Registry: reference/registry.md - Room: reference/room.md + - Space: reference/space.md - Scheduler: reference/scheduler.md - Types: reference/types.md - Examples: From c50595e66511c1b0b94afee7654382518f7b5883 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Tue, 23 Jun 2026 00:19:43 -0400 Subject: [PATCH 5/6] Add get_children in Space --- matrix/space.py | 27 +++++++++++++- tests/test_space.py | 86 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/test_space.py diff --git a/matrix/space.py b/matrix/space.py index b22d28a..853c94f 100644 --- a/matrix/space.py +++ b/matrix/space.py @@ -1,5 +1,30 @@ +from __future__ import annotations + from .room import Room class Space(Room): - pass + def get_children(self) -> list[Room]: + """Return the child rooms and spaces of this space. + + ## Example + + ```python + space = bot.get_space("!space123:matrix.org") + + for child in space.get_children(): + print(child.name) + ``` + """ + children = [] + + for room_id in self.children: + matrix_room = self._client.rooms.get(room_id) + + if not matrix_room: + continue + + room_cls = Space if matrix_room.room_type == "m.space" else Room + children.append(room_cls(matrix_room, self._client)) + + return children diff --git a/tests/test_space.py b/tests/test_space.py new file mode 100644 index 0000000..acfe792 --- /dev/null +++ b/tests/test_space.py @@ -0,0 +1,86 @@ +import pytest +from unittest.mock import AsyncMock, Mock +from nio import MatrixRoom +from matrix.room import Room +from matrix.space import Space + + +@pytest.fixture +def client(): + return AsyncMock() + + +@pytest.fixture +def matrix_space(): + space = MatrixRoom(room_id="!space:example.com", own_user_id="@bot:example.com") + space.name = "Test Space" + space.room_type = "m.space" + return space + + +@pytest.fixture +def space(matrix_space, client): + return Space(matrix_space, client) + + +def test_get_children__with_room_child__expect_room_instance(space, matrix_space, client): + child = MatrixRoom(room_id="!child:example.com", own_user_id="@bot:example.com") + child.name = "Child Room" + matrix_space.children = {"!child:example.com"} + client.rooms = {"!child:example.com": child} + + result = space.get_children() + + assert len(result) == 1 + assert type(result[0]) is Room + assert result[0].room_id == "!child:example.com" + + +def test_get_children__with_space_child__expect_space_instance(space, matrix_space, client): + child = MatrixRoom(room_id="!subspace:example.com", own_user_id="@bot:example.com") + child.name = "Sub Space" + child.room_type = "m.space" + matrix_space.children = {"!subspace:example.com"} + client.rooms = {"!subspace:example.com": child} + + result = space.get_children() + + assert len(result) == 1 + assert isinstance(result[0], Space) + assert result[0].room_id == "!subspace:example.com" + + +def test_get_children__with_unjoined_child__expect_child_skipped(space, matrix_space, client): + matrix_space.children = {"!unknown:example.com"} + client.rooms = {} + + result = space.get_children() + + assert result == [] + + +def test_get_children__with_no_children__expect_empty_list(space, matrix_space, client): + matrix_space.children = set() + client.rooms = {} + + result = space.get_children() + + assert result == [] + + +def test_get_children__with_mixed_children__expect_correct_types(space, matrix_space, client): + room_child = MatrixRoom(room_id="!room:example.com", own_user_id="@bot:example.com") + space_child = MatrixRoom(room_id="!sub:example.com", own_user_id="@bot:example.com") + space_child.room_type = "m.space" + matrix_space.children = {"!room:example.com", "!sub:example.com"} + client.rooms = { + "!room:example.com": room_child, + "!sub:example.com": space_child, + } + + result = space.get_children() + + assert len(result) == 2 + types = {r.room_id: type(r) for r in result} + assert types["!room:example.com"] is Room + assert types["!sub:example.com"] is Space From 1ec15ab92d26cf4c02c85db302ef264627b8f597 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Tue, 23 Jun 2026 01:53:59 -0400 Subject: [PATCH 6/6] Room factory --- matrix/__init__.py | 2 ++ matrix/bot.py | 8 +++----- matrix/room.py | 13 +++++++++++++ matrix/space.py | 9 +++------ tests/test_bot.py | 2 +- tests/test_room.py | 35 ++++++++++++++++++++++++++++++++++- 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/matrix/__init__.py b/matrix/__init__.py index 07bee19..1845a21 100644 --- a/matrix/__init__.py +++ b/matrix/__init__.py @@ -15,6 +15,7 @@ from .help import HelpCommand from .checks import cooldown from .room import Room +from .space import Space from .message import Message from .extension import Extension @@ -28,6 +29,7 @@ "HelpCommand", "cooldown", "Room", + "Space", "Message", "Extension", ] diff --git a/matrix/bot.py b/matrix/bot.py index edf9a2b..8879568 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -7,7 +7,7 @@ from nio import AsyncClient, Event, MatrixRoom -from .room import Room +from .room import Room, make_room from .space import Space from .group import Group from .config import Config @@ -100,8 +100,7 @@ def get_room(self, room_id: str) -> Room | None: ``` """ if matrix_room := self.client.rooms.get(room_id): - room_cls = Space if matrix_room.room_type == "m.space" else Room - return room_cls(matrix_room=matrix_room, client=self.client) + return make_room(matrix_room, self.client) return None def get_rooms(self) -> list[Room]: @@ -123,8 +122,7 @@ def get_rooms(self) -> list[Room]: rooms = [] for matrix_room in self.client.rooms.values(): - room_cls = Space if matrix_room.room_type == "m.space" else Room - rooms.append(room_cls(matrix_room=matrix_room, client=self.client)) + rooms.append(make_room(matrix_room, self.client)) return rooms diff --git a/matrix/room.py b/matrix/room.py index c737070..702d4ff 100644 --- a/matrix/room.py +++ b/matrix/room.py @@ -18,9 +18,22 @@ from matrix.types import File, Image, Audio, Video +_registry: dict[str, type["Room"]] = {} + + +def make_room(matrix_room: MatrixRoom, client: AsyncClient) -> "Room": + room_cls = _registry.get(matrix_room.room_type, Room) + return room_cls(matrix_room, client) + + class Room: """Represents a Matrix room and provides methods to interact with it.""" + def __init_subclass__(cls, room_type: str | None = None, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + if room_type: + _registry[room_type] = cls + def __init__(self, matrix_room: MatrixRoom, client: AsyncClient) -> None: self._matrix_room: MatrixRoom = matrix_room self._client: AsyncClient = client diff --git a/matrix/space.py b/matrix/space.py index 853c94f..4e20189 100644 --- a/matrix/space.py +++ b/matrix/space.py @@ -1,9 +1,7 @@ -from __future__ import annotations +from matrix.room import Room, make_room -from .room import Room - -class Space(Room): +class Space(Room, room_type="m.space"): def get_children(self) -> list[Room]: """Return the child rooms and spaces of this space. @@ -24,7 +22,6 @@ def get_children(self) -> list[Room]: if not matrix_room: continue - room_cls = Space if matrix_room.room_type == "m.space" else Room - children.append(room_cls(matrix_room, self._client)) + children.append(make_room(matrix_room, self._client)) return children diff --git a/tests/test_bot.py b/tests/test_bot.py index bf0b8e6..2125ac0 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from nio import MatrixRoom, RoomMessageText -from matrix.bot import Bot, Config, Extension, Room, Space +from matrix import Bot, Config, Extension, Room, Space from matrix.errors import ( CheckError, CommandNotFoundError, diff --git a/tests/test_room.py b/tests/test_room.py index eafe95b..bd2cb4c 100644 --- a/tests/test_room.py +++ b/tests/test_room.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock, Mock, MagicMock from nio import MatrixRoom, Event from matrix.errors import MatrixError -from matrix.room import Room +from matrix.room import Room, make_room +from matrix.space import Space from matrix.message import Message @@ -387,3 +388,35 @@ def test_room_client_property__expect_async_client(room, client): def test_room_unknown_attribute__expect_attribute_error(room): with pytest.raises(AttributeError): _ = room.nonexistent_attribute + + +def test_make_room__with_regular_room__expect_room_instance(client): + matrix_room = MatrixRoom( + room_id="!room:example.com", own_user_id="@bot:example.com" + ) + + result = make_room(matrix_room, client) + + assert type(result) is Room + + +def test_make_room__with_space_room__expect_space_instance(client): + matrix_room = MatrixRoom( + room_id="!space:example.com", own_user_id="@bot:example.com" + ) + matrix_room.room_type = "m.space" + + result = make_room(matrix_room, client) + + assert type(result) is Space + + +def test_make_room__with_unknown_room_type__expect_room_instance(client): + matrix_room = MatrixRoom( + room_id="!room:example.com", own_user_id="@bot:example.com" + ) + matrix_room.room_type = "m.unknown" + + result = make_room(matrix_room, client) + + assert type(result) is Room