Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions matrix/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,7 @@ def start(self, *, config: Config | str) -> None:
:func:`asyncio.run`, and ensures the client is closed gracefully
on interruption.
"""
if config is not None:
self._load_config(config)
self._load_config(config)

try:
asyncio.run(self.run())
Expand All @@ -264,7 +263,7 @@ async def run(self) -> None:
calls the :meth:`on_ready` hook, and starts the long-running
sync loop for receiving events.
"""
self.client.user = self.config.user_id
self.client.user = self.config.username

self.start_at = time.time()
self.log.info("starting – timestamp=%s", self.start_at)
Expand Down
152 changes: 108 additions & 44 deletions matrix/config.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,125 @@
import yaml
from typing import Any

from envyaml import EnvYAML

from .errors import ConfigError
from typing import Optional


class Config:
"""
Configuration handler for Matrix client settings. Including the following:

homeserver: Defaults to 'https://matrix.org'
user_id: The Matrix user ID (username).
password: (Optional) One of the password or token must be provided.
token: (Optional) One of the password or token must be provided.
prefix: Defaults to '!' if not specified in the config file.

:param config_path: Path to the YAML configuration file.
:param homeserver: The Matrix homeserver URL.
:param username: The Matrix user ID (username).
:param password: The password for the Matrix user.
:param token: The access token for the Matrix user.
:param prefix: The command prefix.

:raises FileNotFoundError: If the configuration file does not exist.
:raises yaml.YAMLError: If the configuration file cannot be parsed.
:raises ConfigError: If neither password or token has been provided.
"""Configuration handler for Matrix client settings.

Manages all settings required to connect and authenticate with a Matrix
homeserver. Configuration can be loaded from a YAML file or provided
directly via constructor parameters. At least one authentication method
must be provided.

# Example

```python
# Load from file
config = Config(config_path="path/to/config..yaml")

# Manual configuration
config = Config(username="@bot:matrix.org", password="secret")
```
"""

def __init__(
self,
config_path: Optional[str] = None,
config_path: str | None = None,
*,
homeserver: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
token: Optional[str] = None,
prefix: Optional[str] = None,
homeserver: str | None = None,
username: str | None = None,
password: str | None = None,
token: str | None = None,
prefix: str | None = None,
) -> None:
"""Initialize the bot configuration.

Loads configuration from a YAML file if provided, otherwise uses
the provided parameters directly. At least one of password or token
must be supplied.

# Example

```python
config = Config(
username="@bot:matrix.org",
password="secret",
prefix="!",
)
```
"""
self._data: dict[str, Any] = {}

self.homeserver: str = homeserver or "https://matrix.org"
self.user_id: Optional[str] = username
self.password: Optional[str] = password
self.token: Optional[str] = token
self.username: str | None = username
self.password: str | None = password
self.token: str | None = token
self.prefix: str = prefix or "!"

if config_path:
self.load_from_file(config_path)
elif not (self.password or self.token):
raise ConfigError("username and password or token")
else:
if not self.password and not self.token:
raise ConfigError("username and password or token")

self._data = {
"HOMESERVER": self.homeserver,
"USERNAME": self.username,
"PASSWORD": self.password,
"TOKEN": self.token,
"PREFIX": self.prefix,
}

def load_from_file(self, config_path: str) -> None:
"""Load Matrix client settings via YAML config file."""
with open(config_path, "r") as f:
config = yaml.safe_load(f)

if not (config.get("PASSWORD") or config.get("TOKEN")):
raise ConfigError("USERNAME and PASSWORD or TOKEN")

self.homeserver = config.get("HOMESERVER", "https://matrix.org")
self.user_id = config.get("USERNAME")
self.password = config.get("PASSWORD", None)
self.token = config.get("TOKEN", None)
self.prefix = config.get("PREFIX", "!")
"""Load Matrix client settings from a YAML config file.

Supports environment variable substitution via EnvYAML. Values in
the YAML file can reference environment variables using ${VAR} syntax.

# Example

```python
config = Config()
config.load_from_file("path/to/config.yaml")
```
"""
self._data = dict(EnvYAML(config_path))

password = self._data.get("PASSWORD", None)
token = self._data.get("TOKEN", None)

if not password and not token:
raise ConfigError("USERNAME and PASSWORD or TOKEN")

self.homeserver = self._data.get("HOMESERVER", "https://matrix.org")
self.username = self._data.get("USERNAME")
self.password = password
self.token = token
self.prefix = self._data.get("PREFIX", "!")

def get(self, key: str, *, section: str | None = None, default: Any = None) -> Any:
"""Access a config value by key, optionally scoped to a section.

# Example

```python
config.get(key="main_channel", section="bot")
config.get(key="log_level", default="INFO")
```
"""
if section in self._data:
return self._data.get(section, {}).get(key, default)
return self._data.get(key, default)

def __getitem__(self, key: str) -> Any:
"""Access a config value by key, raising KeyError if not found.

# Example

```python
config["bot"]["main_channel"]
```
"""
return self._data[key]
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies = [
"PyYAML==6.0.3",
"markdown==3.10.2",
"APScheduler==3.11.2",
"envyaml==1.10.211231",
]

[project.optional-dependencies]
Expand Down
4 changes: 0 additions & 4 deletions tests/config_fixture.yaml

This file was deleted.

1 change: 0 additions & 1 deletion tests/config_fixture_token.yaml

This file was deleted.

67 changes: 49 additions & 18 deletions tests/test_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,30 @@
@pytest.fixture
def bot():
b = Bot()
b._load_config("tests/config_fixture.yaml")
b._load_config(
Config(
username="grace",
password="grace1234",
)
)

b._client = MagicMock()
b._client.room_send = AsyncMock()
b.log = MagicMock()
b.log.getChild.return_value = MagicMock()

return b


@pytest.fixture
def bot_with_token():
b = Bot()
b._load_config(
Config(
username="grace",
token="abc123",
)
)

b._client = MagicMock()
b._client.room_send = AsyncMock()
Expand Down Expand Up @@ -50,7 +73,7 @@ def test_bot_init_with_config():
bot = Bot()
bot._load_config(Config(username="grace", password="grace1234"))

assert bot.config.user_id == "grace"
assert bot.config.username == "grace"
assert bot.config.password == "grace1234"
assert bot.config.homeserver == "https://matrix.org"

Expand Down Expand Up @@ -411,48 +434,51 @@ async def start_and_stop(coro):


@pytest.mark.asyncio
async def test_run_uses_token():
bot = Bot()
bot._load_config("tests/config_fixture_token.yaml")

bot._client.sync_forever = AsyncMock()
bot._on_ready = AsyncMock()
async def test_run_uses_token(bot_with_token):
bot_with_token._client.sync_forever = AsyncMock()
bot_with_token._on_ready = AsyncMock()

# unblock readiness
bot._synced.set()
bot_with_token._synced.set()

task = asyncio.create_task(bot.run())
task = asyncio.create_task(bot_with_token.run())

await asyncio.sleep(0)
await asyncio.sleep(0)

task.cancel()
await asyncio.gather(task, return_exceptions=True)

assert bot._client.access_token == "abc123"
bot._on_ready.assert_awaited_once()
bot._client.sync_forever.assert_awaited_once()
assert bot_with_token._client.access_token == "abc123"
bot_with_token._on_ready.assert_awaited_once()
bot_with_token._client.sync_forever.assert_awaited_once()


@pytest.mark.asyncio
async def test_run_with_username_and_password(bot):
bot._client.login = AsyncMock(return_value="login_resp")
assert bot.config.token is None

login_called = asyncio.Event()

async def mock_login(password):
login_called.set()
return "login_resp"

bot._client.login = AsyncMock(side_effect=mock_login)
bot._client.sync_forever = AsyncMock()
bot._on_ready = AsyncMock()

bot._synced.set()

task = asyncio.create_task(bot.run())

await asyncio.sleep(0)
await asyncio.sleep(0)
await asyncio.wait_for(login_called.wait(), timeout=1.0)

task.cancel()
await asyncio.gather(task, return_exceptions=True)

bot._client.login.assert_awaited_once_with("grace1234")
bot._on_ready.assert_awaited_once()
bot._client.sync_forever.assert_awaited_once()


def test_start_handles_keyboard_interrupt(caplog):
Expand All @@ -463,7 +489,12 @@ def test_start_handles_keyboard_interrupt(caplog):

with patch.object(bot, "_load_config"):
with caplog.at_level("INFO"):
bot.start(config="tests/config_fixture.yaml")
bot.start(
config=Config(
username="grace",
password="grace1234",
)
)

assert "bot interrupted by user" in caplog.text
bot._client.close.assert_awaited_once()
Expand Down
Loading
Loading