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
2 changes: 1 addition & 1 deletion docs/developer-toolkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions easycord/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)


Expand All @@ -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"])
''',
Expand Down
87 changes: 86 additions & 1 deletion easycord/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from __future__ import annotations

import logging
from typing import Callable
from typing import TYPE_CHECKING, Any, Callable

import discord

Expand All @@ -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`.
Expand Down Expand Up @@ -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 = []
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Comment on lines +187 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Guild_id not mapped 🐞 Bug ≡ Correctness

Composer.client_options() accepts guild_id, but Bot.__init__ only consumes sync_guild_id, so
passing guild_id via client_options cannot set the dev sync target and leaves command syncing in
the wrong mode (global vs guild). This is especially likely because the docs/config use guild_id
naming, while Composer exposes sync_guild_id.
Agent Prompt
### Issue description
`Composer.client_options()` only rejects keys listed in `_BOT_OPTION_METHODS`. The repo’s docs/config surface the development sync setting as `guild_id`, but `Bot`/`Composer` use `sync_guild_id`. As a result, `Composer().client_options(guild_id=...)` won’t configure the bot’s sync target.

### Issue Context
- `BotConfig` uses `guild_id` and maps it to `sync_guild_id` when building a `Bot`.
- `Composer` exposes `.sync_guild_id(...)`, but `client_options()` does not reject or translate `guild_id`.

### Fix Focus Areas
- easycord/composer.py[20-38]
- easycord/composer.py[180-195]

### Suggested fix
Choose one:
1) **Reject** `guild_id` in `client_options()` with a clear message directing users to `Composer.sync_guild_id(...)`.
2) **Alias** `guild_id` to `sync_guild_id` (either in `client_options()` or by adding it to `_BOT_OPTION_METHODS` mapping to `sync_guild_id`). Ensure it can’t conflict with an explicit `.sync_guild_id(...)` call (pick a precedence rule and test it).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return self

# ── Built-in middleware ───────────────────────────────────

def log(
Expand Down Expand Up @@ -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)
Expand Down
130 changes: 130 additions & 0 deletions tests/test_composer.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Tests import easycord.composer 📘 Rule violation ⌂ Architecture

tests/test_composer.py imports Composer from the internal submodule easycord.composer instead
of the public top-level easycord API. This breaks the public-API-only import rule for code outside
the easycord/ package and increases coupling to internal module layout.
Agent Prompt
## Issue description
A test file outside the `easycord/` package imports `Composer` via `from easycord.composer import Composer`, but external code must import only from the public top-level `easycord` package API.

## Issue Context
`Composer` is already re-exported from `easycord/__init__.py`, so the test can import it as `from easycord import Composer`.

## Fix Focus Areas
- tests/test_composer.py[9-9]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



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()})
8 changes: 6 additions & 2 deletions tests/test_developer_toolkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Comment on lines +248 to +251

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add an assertion that the community template does not instantiate Bot directly to fully guarantee composition-first setup.

The current assertions ensure the template uses Composer with the expected calls. To fully enforce the composition-first invariant, also assert that Bot( does not appear in bot_source (e.g. assert "Bot(" not in bot_source) so tests fail if a direct Bot instantiation is reintroduced alongside Composer.

assert "ModerationPlugin()" in bot_source
assert "EconomyPlugin()" in bot_source
assert "ReminderPlugin()" in bot_source
Expand Down
Loading