Skip to content

Commit 42ed8cf

Browse files
JacobCoffeeclaude
andauthored
fix: bot configuration loading from environment variables (#131)
Co-authored-by: Claude <[email protected]>
1 parent e39c175 commit 42ed8cf

File tree

2 files changed

+121
-39
lines changed

2 files changed

+121
-39
lines changed

services/bot/src/byte_bot/config.py

Lines changed: 10 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77

88
from dotenv import load_dotenv
9-
from pydantic import ValidationError, field_validator
9+
from pydantic import Field, ValidationError, field_validator
1010
from pydantic_settings import BaseSettings, SettingsConfigDict
1111

1212
__all__ = [
@@ -29,34 +29,33 @@ class BotSettings(BaseSettings):
2929
model_config = SettingsConfigDict(
3030
case_sensitive=True,
3131
env_file=".env",
32-
env_prefix="BOT_",
3332
extra="ignore",
3433
)
3534

3635
# Discord configuration
37-
discord_token: str
38-
"""Discord API token (from DISCORD_TOKEN or BOT_DISCORD_TOKEN)."""
39-
discord_dev_guild_id: int | None = None
36+
discord_token: str = Field(..., validation_alias="DISCORD_TOKEN")
37+
"""Discord API token (from DISCORD_TOKEN)."""
38+
discord_dev_guild_id: int | None = Field(default=None, validation_alias="DISCORD_DEV_GUILD_ID")
4039
"""Discord Guild ID for development."""
41-
discord_dev_user_id: int | None = None
40+
discord_dev_user_id: int | None = Field(default=None, validation_alias="DISCORD_DEV_USER_ID")
4241
"""Discord User ID for development."""
4342
command_prefix: list[str] = ["!"]
4443
"""Command prefix for bot commands."""
4544
presence_url: str = ""
4645
"""Bot presence URL."""
4746

4847
# API service configuration
49-
api_service_url: str = "http://localhost:8000"
48+
api_service_url: str = Field(default="http://localhost:8000", validation_alias="API_SERVICE_URL")
5049
"""Base URL for the API service."""
5150

5251
# Plugin configuration
5352
plugins_dir: Path = PLUGINS_DIR
5453
"""Path to plugins directory."""
5554

5655
# Environment
57-
environment: str = "dev"
56+
environment: str = Field(default="dev", validation_alias="ENVIRONMENT")
5857
"""Environment: dev, test, or prod."""
59-
debug: bool = False
58+
debug: bool = Field(default=False, validation_alias="DEBUG")
6059
"""Enable debug mode."""
6160

6261
@field_validator("command_prefix")
@@ -102,41 +101,13 @@ def assemble_presence_url(cls, value: str) -> str: # noqa: ARG003
102101
environment = os.getenv("ENVIRONMENT", "dev")
103102
return os.getenv("PRESENCE_URL", env_urls.get(environment, "https://dev.byte-bot.app/"))
104103

105-
@field_validator("discord_token", mode="before")
106-
@classmethod
107-
def get_discord_token(cls, value: str | None) -> str:
108-
"""Get Discord token from environment.
109-
110-
Supports both DISCORD_TOKEN and BOT_DISCORD_TOKEN.
111-
112-
Args:
113-
value: Token value from pydantic
114-
115-
Returns:
116-
Discord token
117-
118-
Raises:
119-
ValueError: If token is not set
120-
"""
121-
if value:
122-
return value
123-
124-
# Try BOT_DISCORD_TOKEN first, then DISCORD_TOKEN
125-
token = os.getenv("BOT_DISCORD_TOKEN") or os.getenv("DISCORD_TOKEN")
126-
if not token:
127-
msg = "Discord token must be set via DISCORD_TOKEN or BOT_DISCORD_TOKEN"
128-
raise ValueError(msg)
129-
130-
return token
131-
132104

133105
class LogSettings(BaseSettings):
134106
"""Logging configuration for the bot service."""
135107

136108
model_config = SettingsConfigDict(
137109
case_sensitive=True,
138110
env_file=".env",
139-
env_prefix="LOG_",
140111
extra="ignore",
141112
)
142113

@@ -166,8 +137,8 @@ def load_settings() -> tuple[BotSettings, LogSettings]:
166137
ValidationError: If settings validation fails
167138
"""
168139
try:
169-
bot = BotSettings.model_validate({})
170-
log = LogSettings.model_validate({})
140+
bot = BotSettings()
141+
log = LogSettings()
171142
except ValidationError as error:
172143
print(f"Could not load settings. Error: {error!r}") # noqa: T201
173144
raise

tests/unit/bot/test_config.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Tests for bot configuration loading."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
8+
class TestBotSettings:
9+
"""Tests for BotSettings class."""
10+
11+
def test_bot_settings_loads_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
12+
"""Test that BotSettings loads from environment variables."""
13+
from byte_bot.config import BotSettings
14+
15+
# Set up environment variables
16+
monkeypatch.setenv("DISCORD_TOKEN", "test-token-123")
17+
monkeypatch.setenv("DISCORD_DEV_GUILD_ID", "987654321")
18+
monkeypatch.setenv("API_SERVICE_URL", "http://test-api:9000")
19+
monkeypatch.setenv("ENVIRONMENT", "test")
20+
21+
# Load settings
22+
settings = BotSettings()
23+
24+
# Verify settings loaded correctly
25+
assert settings.discord_token == "test-token-123"
26+
assert settings.discord_dev_guild_id == 987654321
27+
assert settings.api_service_url == "http://test-api:9000"
28+
assert settings.environment == "test"
29+
30+
def test_command_prefix_assembly(self, monkeypatch: pytest.MonkeyPatch) -> None:
31+
"""Test command prefix assembly based on environment."""
32+
from byte_bot.config import BotSettings
33+
34+
monkeypatch.setenv("DISCORD_TOKEN", "test-token")
35+
monkeypatch.setenv("ENVIRONMENT", "dev")
36+
37+
settings = BotSettings()
38+
39+
# Dev environment should have "nibble " prefix
40+
assert "nibble " in settings.command_prefix
41+
42+
def test_presence_url_assembly(self, monkeypatch: pytest.MonkeyPatch) -> None:
43+
"""Test presence URL assembly based on environment."""
44+
from byte_bot.config import BotSettings
45+
46+
monkeypatch.setenv("DISCORD_TOKEN", "test-token")
47+
monkeypatch.setenv("ENVIRONMENT", "dev")
48+
49+
settings = BotSettings()
50+
51+
# Dev environment should use dev URL
52+
assert "dev.byte-bot.app" in settings.presence_url
53+
54+
55+
class TestLogSettings:
56+
"""Tests for LogSettings class."""
57+
58+
def test_log_settings_has_defaults(self) -> None:
59+
"""Test that LogSettings has reasonable default values."""
60+
from byte_bot.config import LogSettings
61+
62+
settings = LogSettings()
63+
64+
# Should have default values (may be from env or defaults)
65+
assert isinstance(settings.level, int)
66+
assert isinstance(settings.discord_level, int)
67+
assert isinstance(settings.websockets_level, int)
68+
assert isinstance(settings.asyncio_level, int)
69+
assert isinstance(settings.httpx_level, int)
70+
71+
72+
class TestLoadSettings:
73+
"""Tests for load_settings function."""
74+
75+
def test_load_settings_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
76+
"""Test successful settings loading."""
77+
from byte_bot.config import load_settings
78+
79+
monkeypatch.setenv("DISCORD_TOKEN", "test-token")
80+
81+
# Reimport to avoid cached module-level settings
82+
from importlib import reload
83+
84+
import byte_bot.config
85+
86+
reload(byte_bot.config)
87+
from byte_bot.config import BotSettings, LogSettings
88+
89+
bot_settings, log_settings = load_settings()
90+
91+
assert isinstance(bot_settings, BotSettings)
92+
assert isinstance(log_settings, LogSettings)
93+
assert bot_settings.discord_token
94+
95+
def test_load_settings_returns_tuple(self, monkeypatch: pytest.MonkeyPatch) -> None:
96+
"""Test that load_settings returns a tuple of settings."""
97+
from byte_bot.config import load_settings
98+
99+
monkeypatch.setenv("DISCORD_TOKEN", "test-token")
100+
101+
from importlib import reload
102+
103+
import byte_bot.config
104+
105+
reload(byte_bot.config)
106+
from byte_bot.config import BotSettings, LogSettings
107+
108+
bot_settings, log_settings = load_settings()
109+
110+
assert isinstance(bot_settings, BotSettings)
111+
assert isinstance(log_settings, LogSettings)

0 commit comments

Comments
 (0)