diff --git a/matrix/bot.py b/matrix/bot.py index 83f8e67..3dad2df 100644 --- a/matrix/bot.py +++ b/matrix/bot.py @@ -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()) @@ -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) diff --git a/matrix/config.py b/matrix/config.py index e6e5dd0..aeefe9f 100644 --- a/matrix/config.py +++ b/matrix/config.py @@ -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] diff --git a/pyproject.toml b/pyproject.toml index 304ff40..d25e9ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "PyYAML==6.0.3", "markdown==3.10.2", "APScheduler==3.11.2", + "envyaml==1.10.211231", ] [project.optional-dependencies] diff --git a/tests/config_fixture.yaml b/tests/config_fixture.yaml deleted file mode 100644 index 9ae7deb..0000000 --- a/tests/config_fixture.yaml +++ /dev/null @@ -1,4 +0,0 @@ -HOMESERVER: "matrix.fixture.org" -USERNAME: "grace" -PASSWORD: "grace1234" -PREFIX: "!" \ No newline at end of file diff --git a/tests/config_fixture_token.yaml b/tests/config_fixture_token.yaml deleted file mode 100644 index 99dc0e2..0000000 --- a/tests/config_fixture_token.yaml +++ /dev/null @@ -1 +0,0 @@ -TOKEN: "abc123" \ No newline at end of file diff --git a/tests/test_bot.py b/tests/test_bot.py index 4d52a0e..d98a27c 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -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() @@ -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" @@ -411,17 +434,14 @@ 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) @@ -429,14 +449,22 @@ async def test_run_uses_token(): 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() @@ -444,15 +472,13 @@ async def test_run_with_username_and_password(bot): 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): @@ -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() diff --git a/tests/test_config.py b/tests/test_config.py index fc3421d..4ae7e36 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,11 @@ +import os +from unittest.mock import patch + import pytest import yaml -from matrix.errors import ConfigError from matrix.config import Config +from matrix.errors import ConfigError @pytest.fixture @@ -10,11 +13,66 @@ def config_default(): return Config(username="grace", password="secret") +@pytest.fixture +def config_file(tmp_path): + config = tmp_path / "test.yaml" + config.write_text( + "USERNAME: '@bot:matrix.org'\n" + "PASSWORD: 'secret'\n" + "PREFIX: '!'\n" + "LOG_LEVEL: 'INFO'\n" + "bot:\n" + " main_channel: '!abc123:matrix.org'\n" + ) + return config + + +@pytest.fixture +def config(config_file): + return Config(config_path=str(config_file)) + + +def test_get__returns_top_level_value(config: Config) -> None: + assert config.get(key="LOG_LEVEL") == "INFO" + + +def test_get__returns_none_when_key_missing_and_no_default(config: Config) -> None: + assert config.get(key="MISSING") is None + + +def test_get__returns_default_when_key_missing(config: Config) -> None: + assert config.get(key="MISSING", default="fallback") == "fallback" + + +def test_get__returns_section_value(config: Config) -> None: + assert config.get(key="main_channel", section="bot") == "!abc123:matrix.org" + + +def test_get__returns_default_when_section_missing(config: Config) -> None: + assert ( + config.get(key="main_channel", section="MISSING", default="fallback") + == "fallback" + ) + + +def test_get__returns_default_when_section_key_missing(config: Config) -> None: + assert config.get(key="MISSING", section="bot", default="fallback") == "fallback" + + +def test_getitem__returns_value(config: Config) -> None: + assert config["LOG_LEVEL"] == "INFO" + + +def test_getitem__raises_key_error_when_missing(config: Config) -> None: + with pytest.raises(KeyError): + _ = config["MISSING"] + + @pytest.mark.parametrize( "attr,expected", [ ("homeserver", "https://matrix.org"), - ("user_id", "grace"), + ("username", "grace"), ("password", "secret"), ("token", None), ("prefix", "!"), @@ -36,7 +94,7 @@ def test_loading_valid_yaml(tmp_path): cfg = Config(str(config_file)) - assert cfg.user_id == "@grace:matrix.org" + assert cfg.username == "@grace:matrix.org" assert cfg.password == "grace1234" assert cfg.prefix == "/" @@ -62,14 +120,14 @@ def test_missing_credentials_raises_ConfigError_kwargs(): def test_missing_credentials_raises_ConfigError_yaml(tmp_path): - yaml_text = "HOMESERVER: https://matrix.org" + yaml_text = "HOMESERVER: https://matrix.org\n" "PASSWORD: \n" "TOKEN: \n" file = tmp_path / "err.yaml" file.write_text(yaml_text) - with pytest.raises(ConfigError) as exc: - Config(str(file)) - # the assert make sure that the error is raised from - # the load_from_file method and not the constructor - assert "USERNAME and PASSWORD or TOKEN" in str(exc.value) + + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ConfigError) as exc: + Config(config_path=str(file)) + assert "USERNAME and PASSWORD or TOKEN" in str(exc.value) def test_token_only():