diff --git a/docs/developer-toolkit.md b/docs/developer-toolkit.md index 85abd6e..1f719b5 100644 --- a/docs/developer-toolkit.md +++ b/docs/developer-toolkit.md @@ -34,7 +34,7 @@ easycord new storage-bot --template database - `minimal`: one `bot.py` with a slash command and a command test. - `plugin`: a plugin-oriented project; this is the default. -- `community`: a composition-first bot with bundled community plugins and tests. +- `community`: a composition-first Composer bot with bundled community plugins and tests. - `ai`: a plugin scaffold with a friendly AI-provider placeholder command. - `database`: a plugin scaffold showing SQLite app setup and in-memory tests. diff --git a/docs/getting-started.md b/docs/getting-started.md index e0672c0..049fd66 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -115,6 +115,25 @@ async def hello(ctx): Create a custom `Plugin` class when a feature needs shared state, lifecycle hooks, event handlers, or several related commands. +For bots with several framework options, `Composer` keeps setup readable while +building the same `Bot` object: + +```python +from easycord import Composer +from easycord.plugins import LevelsPlugin, PollsPlugin + +bot = ( + Composer() + .auto_sync(False) + .health_command() + .add_plugins(LevelsPlugin(), PollsPlugin()) + .build() +) +``` + +Use direct `Bot(...)` construction when it is shorter; use `Composer` when a +bot combines several services, middleware layers, or plugins. + For reusable plugins, start from the plugin authoring helpers instead of copying files by hand. Generated plugin projects include a required manifest, runnable bot examples default to local storage, and generated tests use memory storage diff --git a/easycord/cli.py b/easycord/cli.py index 6d1683b..0fc9e3b 100644 --- a/easycord/cli.py +++ b/easycord/cli.py @@ -202,14 +202,21 @@ def _community_project_files(name: str) -> dict[str, str]: "bot.py": '''\ import os - from easycord import Bot, SQLiteDatabase + from easycord import Composer, SQLiteDatabase from easycord.plugins import EconomyPlugin, ModerationPlugin, ReminderPlugin - bot = Bot( - auto_sync=False, - database=SQLiteDatabase(path="data/bot.db"), - load_builtin_plugins=True, # WelcomePlugin, TagsPlugin, PollsPlugin, LevelsPlugin + bot = ( + Composer() + .auto_sync(False) + .database(SQLiteDatabase(path="data/bot.db")) + .builtin_plugins() # WelcomePlugin, TagsPlugin, PollsPlugin, LevelsPlugin + .add_plugins( + ModerationPlugin(), + EconomyPlugin(), + ReminderPlugin(), + ) + .build() ) @@ -219,13 +226,6 @@ async def info(ctx): await ctx.respond(f"{guild.name} has {guild.member_count} members.") - bot.add_plugins( - ModerationPlugin(), - EconomyPlugin(), - ReminderPlugin(), - ) - - if __name__ == "__main__": bot.run(os.environ["DISCORD_TOKEN"]) ''', diff --git a/easycord/composer.py b/easycord/composer.py index fdaf9c3..e4a7959 100644 --- a/easycord/composer.py +++ b/easycord/composer.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Callable +from typing import TYPE_CHECKING, Any, Callable import discord @@ -13,6 +13,30 @@ from .middleware import MiddlewareFn from .plugin import Plugin +if TYPE_CHECKING: + from .plugins._ai_providers import AIProviderProtocol + + +_BOT_OPTION_METHODS = { + "intents": "intents", + "auto_sync": "auto_sync", + "sync_guild_id": "sync_guild_id", + "load_builtin_plugins": "builtin_plugins", + "database": "database", + "db_backend": "db_backend", + "db_path": "db_path", + "db_auto_sync_guilds": "db_auto_sync_guilds", + "guild_sync_timeout": "guild_sync_timeout", + "localization": "localization", + "default_locale": "default_locale", + "translations": "translations", + "auto_translator": "auto_translator", + "ai_provider": "ai_provider", + "enable_conversation_memory": "conversation_memory", + "enable_health_command": "health_command", + "cooldown_cleanup_interval": "cooldown_cleanup_interval", +} + class Composer: """Fluent builder for composing a :class:`~easycord.Bot`. @@ -43,15 +67,22 @@ class Composer: def __init__(self) -> None: self._intents: discord.Intents | None = None self._auto_sync: bool = True + self._sync_guild_id: int | None = None self._load_builtin_plugins: bool = False self._database: EasyCordDatabase | None = None self._db_backend: str | None = None self._db_path: str | None = None self._db_auto_sync_guilds: bool | None = None + self._guild_sync_timeout: float | None = 30.0 self._localization: LocalizationManager | None = None self._default_locale: str = "en-US" self._translations: dict | None = None self._auto_translator: Callable[[str, str, str], str | None] | None = None + self._ai_provider: AIProviderProtocol | None = None + self._enable_conversation_memory: bool = False + self._enable_health_command: bool = False + self._cooldown_cleanup_interval: float = 600.0 + self._client_options: dict[str, Any] = {} self._middleware: list[MiddlewareFn] = [] self._plugins: list[Plugin] = [] self._groups: list = [] @@ -68,6 +99,11 @@ def auto_sync(self, enabled: bool = True) -> Composer: self._auto_sync = enabled return self + def sync_guild_id(self, guild_id: int | None) -> Composer: + """Set the development guild used for command syncing.""" + self._sync_guild_id = guild_id + return self + def builtin_plugins(self, enabled: bool = True) -> Composer: """Enable or disable the bundled first-party plugin pack.""" self._load_builtin_plugins = enabled @@ -93,6 +129,11 @@ def db_auto_sync_guilds(self, enabled: bool = True) -> Composer: self._db_auto_sync_guilds = enabled return self + def guild_sync_timeout(self, timeout: float | None) -> Composer: + """Set the database guild-sync timeout in seconds.""" + self._guild_sync_timeout = timeout + return self + def localization(self, manager: LocalizationManager) -> Composer: """Use an explicit localization manager instance.""" self._localization = manager @@ -116,6 +157,43 @@ def auto_translator( self._auto_translator = translator return self + def ai_provider(self, provider: AIProviderProtocol | None) -> Composer: + """Set the default AI provider used by contexts and plugins.""" + self._ai_provider = provider + return self + + def conversation_memory(self, enabled: bool = True) -> Composer: + """Enable or disable built-in AI conversation memory.""" + self._enable_conversation_memory = enabled + return self + + def health_command(self, enabled: bool = True) -> Composer: + """Enable or disable the built-in health command.""" + self._enable_health_command = enabled + return self + + def cooldown_cleanup_interval(self, seconds: float) -> Composer: + """Set the cooldown registry cleanup interval in seconds.""" + self._cooldown_cleanup_interval = seconds + return self + + def client_options(self, **options: Any) -> Composer: + """Merge options forwarded to :class:`discord.Client`. + + EasyCord-owned :class:`Bot` options must use their dedicated Composer + methods so they cannot collide with arguments supplied by + :meth:`build`. + """ + for option in options: + method = _BOT_OPTION_METHODS.get(option) + if method is not None: + raise ValueError( + f"{option!r} is an EasyCord Bot option; " + f"use Composer.{method}(...) instead of client_options()." + ) + self._client_options.update(options) + return self + # ── Built-in middleware ─────────────────────────────────── def log( @@ -253,15 +331,22 @@ def build(self) -> Bot: bot = Bot( intents=self._intents, auto_sync=self._auto_sync, + sync_guild_id=self._sync_guild_id, load_builtin_plugins=self._load_builtin_plugins, database=self._database, db_backend=self._db_backend, db_path=self._db_path, db_auto_sync_guilds=self._db_auto_sync_guilds, + guild_sync_timeout=self._guild_sync_timeout, localization=self._localization, default_locale=self._default_locale, translations=self._translations, auto_translator=self._auto_translator, + ai_provider=self._ai_provider, + enable_conversation_memory=self._enable_conversation_memory, + enable_health_command=self._enable_health_command, + cooldown_cleanup_interval=self._cooldown_cleanup_interval, + **self._client_options, ) for mw in self._middleware: bot.use(mw) diff --git a/tests/test_composer.py b/tests/test_composer.py new file mode 100644 index 0000000..10e0f90 --- /dev/null +++ b/tests/test_composer.py @@ -0,0 +1,130 @@ +"""Focused tests for Composer-to-Bot option parity.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import discord +import pytest + +from easycord.composer import Composer + + +def test_build_preserves_bot_option_defaults() -> None: + with patch("easycord.composer.Bot") as bot_class: + Composer().build() + + bot_class.assert_called_once_with( + intents=None, + auto_sync=True, + sync_guild_id=None, + load_builtin_plugins=False, + database=None, + db_backend=None, + db_path=None, + db_auto_sync_guilds=None, + guild_sync_timeout=30.0, + localization=None, + default_locale="en-US", + translations=None, + auto_translator=None, + ai_provider=None, + enable_conversation_memory=False, + enable_health_command=False, + cooldown_cleanup_interval=600.0, + ) + + +def test_new_bot_and_client_options_are_forwarded() -> None: + provider = MagicMock() + activity = discord.Game(name="EasyCord") + mentions = discord.AllowedMentions.none() + + with patch("easycord.composer.Bot") as bot_class: + result = ( + Composer() + .sync_guild_id(123) + .guild_sync_timeout(None) + .ai_provider(provider) + .conversation_memory() + .health_command() + .cooldown_cleanup_interval(45.0) + .client_options(activity=activity, allowed_mentions=mentions) + .build() + ) + + assert result is bot_class.return_value + kwargs = bot_class.call_args.kwargs + assert kwargs["sync_guild_id"] == 123 + assert kwargs["guild_sync_timeout"] is None + assert kwargs["ai_provider"] is provider + assert kwargs["enable_conversation_memory"] is True + assert kwargs["enable_health_command"] is True + assert kwargs["cooldown_cleanup_interval"] == 45.0 + assert kwargs["activity"] is activity + assert kwargs["allowed_mentions"] is mentions + + +def test_boolean_options_can_be_disabled_explicitly() -> None: + with patch("easycord.composer.Bot") as bot_class: + ( + Composer() + .conversation_memory(True) + .conversation_memory(False) + .health_command(True) + .health_command(False) + .build() + ) + + kwargs = bot_class.call_args.kwargs + assert kwargs["enable_conversation_memory"] is False + assert kwargs["enable_health_command"] is False + + +def test_repeated_client_options_merge_with_later_values_winning() -> None: + first_activity = discord.Game(name="First") + second_activity = discord.Game(name="Second") + + with patch("easycord.composer.Bot") as bot_class: + ( + Composer() + .client_options(activity=first_activity, max_messages=100) + .client_options(activity=second_activity) + .build() + ) + + kwargs = bot_class.call_args.kwargs + assert kwargs["activity"] is second_activity + assert kwargs["max_messages"] == 100 + + +@pytest.mark.parametrize( + ("option", "method"), + [ + ("intents", "intents"), + ("auto_sync", "auto_sync"), + ("sync_guild_id", "sync_guild_id"), + ("load_builtin_plugins", "builtin_plugins"), + ("database", "database"), + ("db_backend", "db_backend"), + ("db_path", "db_path"), + ("db_auto_sync_guilds", "db_auto_sync_guilds"), + ("guild_sync_timeout", "guild_sync_timeout"), + ("localization", "localization"), + ("default_locale", "default_locale"), + ("translations", "translations"), + ("auto_translator", "auto_translator"), + ("ai_provider", "ai_provider"), + ("enable_conversation_memory", "conversation_memory"), + ("enable_health_command", "health_command"), + ("cooldown_cleanup_interval", "cooldown_cleanup_interval"), + ], +) +def test_client_options_reject_easycord_owned_options( + option: str, + method: str, +) -> None: + with pytest.raises( + ValueError, + match=rf"{option!r}.*Composer\.{method}", + ): + Composer().client_options(**{option: object()}) diff --git a/tests/test_developer_toolkit.py b/tests/test_developer_toolkit.py index 11dd420..de72932 100644 --- a/tests/test_developer_toolkit.py +++ b/tests/test_developer_toolkit.py @@ -211,7 +211,8 @@ def test_cli_new_template_options( assert (project / "bot.py").exists() assert (project / "tests" / "test_bot.py").exists() assert (project / "plugins" / f"{template}_bot.py").exists() is has_plugin - assert "auto_sync=False" in (project / "bot.py").read_text(encoding="utf-8") + bot_source = (project / "bot.py").read_text(encoding="utf-8") + assert "auto_sync=False" in bot_source or ".auto_sync(False)" in bot_source def test_cli_new_defaults_to_plugin_template(tmp_path: Path, capsys) -> None: @@ -244,7 +245,10 @@ def test_cli_new_community_template_is_composition_first(tmp_path: Path, capsys) bot_source = (project / "bot.py").read_text(encoding="utf-8") test_source = (project / "tests" / "test_bot.py").read_text(encoding="utf-8") - assert "load_builtin_plugins=True" in bot_source + assert "Composer()" in bot_source + assert ".auto_sync(False)" in bot_source + assert ".builtin_plugins()" in bot_source + assert ".build()" in bot_source assert "ModerationPlugin()" in bot_source assert "EconomyPlugin()" in bot_source assert "ReminderPlugin()" in bot_source