From 9d57a072bf36c4ecde2a09a7ab4fc9585238baea Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Fri, 19 Dec 2025 21:44:19 -0500 Subject: [PATCH 1/4] [Feature] - logging in cogs --- capy_discord/__main__.py | 3 +- capy_discord/exts/tools/ping.py | 23 +++++++-- capy_discord/exts/tools/sync.py | 33 ++++++------- capy_discord/logging.py | 85 ++++++++++++++++++++++++--------- 4 files changed, 100 insertions(+), 44 deletions(-) diff --git a/capy_discord/__main__.py b/capy_discord/__main__.py index c467e41..41f8415 100644 --- a/capy_discord/__main__.py +++ b/capy_discord/__main__.py @@ -9,8 +9,9 @@ def main() -> None: """Main function to run the application.""" setup_logging() + capy_discord.instance = Bot(command_prefix=settings.prefix, intents=discord.Intents.all()) - capy_discord.instance.run(settings.token) + capy_discord.instance.run(settings.token, log_handler=None) main() diff --git a/capy_discord/exts/tools/ping.py b/capy_discord/exts/tools/ping.py index 0e38a36..af23205 100644 --- a/capy_discord/exts/tools/ping.py +++ b/capy_discord/exts/tools/ping.py @@ -3,19 +3,32 @@ from discord.ext import commands import capy_discord +from capy_discord.logging import get_logger -class PingCog(commands.Cog): +class Ping(commands.Cog): """Cog for ping command.""" + def __init__(self) -> None: + """Initialize the Ping cog.""" + self.logger = get_logger(__name__) + @app_commands.command(name="ping", description="Shows the bot's latency") async def ping(self, interaction: discord.Interaction) -> None: """Respond with the bot's latency.""" - message = f"⏱ {round(capy_discord.instance.latency * 1000)} ms Latency!" - embed = discord.Embed(title="Ping", description=message) - await interaction.response.send_message(embed=embed) + try: + latency = round(capy_discord.instance.latency * 1000) # in ms + message = f"Pong! {latency} ms Latency!" + embed = discord.Embed(title="Ping", description=message) + self.logger.info(f"/ping invoked user: {interaction.user.id} guild: {interaction.guild_id}") + + await interaction.response.send_message(embed=embed) + + except Exception: + self.logger.exception("/ping attempted user") + await interaction.response.send_message("We're sorry, this interaction failed. Please contact an admin.") async def setup(bot: commands.Bot) -> None: """Set up the Ping cog.""" - await bot.add_cog(PingCog()) + await bot.add_cog(Ping()) diff --git a/capy_discord/exts/tools/sync.py b/capy_discord/exts/tools/sync.py index 0939382..70d187c 100644 --- a/capy_discord/exts/tools/sync.py +++ b/capy_discord/exts/tools/sync.py @@ -4,8 +4,6 @@ - Manual sync via command - Slash command sync - Global sync - -#TODO: Add sync status tracking """ import discord @@ -13,20 +11,20 @@ from discord.ext import commands import capy_discord +from capy_discord.logging import get_logger -class SyncCog(commands.Cog): +class Sync(commands.Cog): """Cog for synchronizing application commands.""" - async def _sync_commands(self) -> list[discord.app_commands.AppCommand]: - """Synchronize commands with Discord. - - Returns: - List of synced commands + def __init__(self) -> None: + """Initialize the Sync cog.""" + self.logger = get_logger(__name__) - #! Note: This operation can be rate limited - """ + async def _sync_commands(self) -> list[discord.app_commands.AppCommand]: + """Synchronize commands with Discord.""" synced_commands: list[discord.app_commands.AppCommand] = await capy_discord.instance.tree.sync() + self.logger.info(f"_sync_commands internal: {synced_commands}") return synced_commands @commands.command(name="sync", hidden=True) @@ -36,24 +34,27 @@ async def sync(self, ctx: commands.Context[commands.Bot]) -> None: synced = await self._sync_commands() description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}" + self.logger.info(f"!sync invoked user: {ctx.author.id} guild: {ctx.guild.id}") await ctx.send(description) - except Exception as e: - await ctx.send(f"Failed to sync commands: {e}") + except Exception: + self.logger.exception("!sync attempted with error") + await ctx.send("We're sorry, this interaction failed. Please contact an admin.") @app_commands.command(name="sync", description="Sync application commands") async def sync_slash(self, interaction: discord.Interaction) -> None: """Sync commands via slash command.""" try: synced = await self._sync_commands() - description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}" + self.logger.info(f"/sync invoked user: {interaction.user.id} guild: {interaction.guild_id}") await interaction.response.send_message(description) - except Exception as e: - await interaction.response.send_message(f"Failed to sync commands: {e}") + except Exception: + self.logger.exception("/sync attempted user with error") + await interaction.response.send_message("We're sorry, this interaction failed. Please contact an admin.") async def setup(bot: commands.Bot) -> None: """Set up the Sync cog.""" - await bot.add_cog(SyncCog(bot)) + await bot.add_cog(Sync()) diff --git a/capy_discord/logging.py b/capy_discord/logging.py index 38f672c..224427c 100644 --- a/capy_discord/logging.py +++ b/capy_discord/logging.py @@ -1,31 +1,72 @@ -import datetime import logging -import logging.handlers +import sys from pathlib import Path +from typing import Final -from capy_discord.config import settings +# Standard format: [Time] [Level] [Logger Name]: Message +LOG_FORMAT: Final[str] = "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s" +DATE_FORMAT: Final[str] = "%Y-%m-%d %H:%M:%S" -def setup_logging() -> None: - """Set up logging for the application.""" - log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - log_level = logging.getLevelNamesMapping()[settings.log_level.upper()] - log_file = f"{datetime.datetime.now(datetime.UTC).date()}.log" +class ColoredFormatter(logging.Formatter): + """A custom logging formatter that adds colors to the output.""" - # Create logs directory if it doesn't exist + def __init__(self, fmt: str, datefmt: str) -> None: + """Initialize the ColoredFormatter.""" + super().__init__(fmt, datefmt) + self.level_colors = { + logging.INFO: "\033[92m", # Green + logging.WARNING: "\033[93m", # Yellow + logging.ERROR: "\033[91m", # Red + logging.CRITICAL: "\033[91m", # Red + logging.DEBUG: "\033[94m", # Blue + } + self.name_color = "\033[96m" # Cyan + self.reset = "\033[0m" + + def format(self, record: logging.LogRecord) -> str: + """Format the log record.""" + # Get the color for the level + level_color = self.level_colors.get(record.levelno, "") + + # Temporarily add color to the levelname and name + original_levelname = record.levelname + original_name = record.name + + record.levelname = f"{level_color}{original_levelname}{self.reset}" + record.name = f"{self.name_color}{original_name}{self.reset}" + + # Format the message + formatted_message = super().format(record) + + # Restore the original values + record.levelname = original_levelname + record.name = original_name + + return formatted_message + + +def get_logger(name: str) -> logging.Logger: + """Get a logger instance with the specified name.""" + return logging.getLogger(name) + + +def setup_logging(level: int = logging.INFO) -> None: + """Set up the logging configuration with the specified level.""" + root_logger = logging.getLogger() + root_logger.setLevel(level) + + # Create a handler that writes to stdout + handler = logging.StreamHandler(sys.stdout) + formatter = ColoredFormatter(LOG_FORMAT, datefmt=DATE_FORMAT) + handler.setFormatter(formatter) + + # Create a log directory if it doesn't exist log_dir = Path("logs") log_dir.mkdir(exist_ok=True) - # Root logger - logger = logging.getLogger() - logger.setLevel(log_level) - - # File handler - file_handler = logging.handlers.RotatingFileHandler( - log_dir / log_file, - maxBytes=1024 * 1024 * 5, # 5 MB - backupCount=5, - encoding="utf-8", - ) - file_handler.setFormatter(logging.Formatter(log_format)) - logger.addHandler(file_handler) + # Removing previous handlers to avoid duplicate logs from discord after setup_logging invokation + if root_logger.hasHandlers(): + root_logger.handlers.clear() + + root_logger.addHandler(handler) From 6b8cc849f53ce2f814347914d600b71255fa1c86 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Mon, 12 Jan 2026 22:45:01 -0500 Subject: [PATCH 2/4] [Feature] - log files --- capy_discord/__main__.py | 2 +- capy_discord/exts/tools/ping.py | 2 +- capy_discord/exts/tools/sync.py | 6 +++--- capy_discord/logging.py | 27 ++++++++++++++++++++++----- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/capy_discord/__main__.py b/capy_discord/__main__.py index 41f8415..84fbb76 100644 --- a/capy_discord/__main__.py +++ b/capy_discord/__main__.py @@ -8,7 +8,7 @@ def main() -> None: """Main function to run the application.""" - setup_logging() + setup_logging(settings.log_level) capy_discord.instance = Bot(command_prefix=settings.prefix, intents=discord.Intents.all()) capy_discord.instance.run(settings.token, log_handler=None) diff --git a/capy_discord/exts/tools/ping.py b/capy_discord/exts/tools/ping.py index af23205..b48eef8 100644 --- a/capy_discord/exts/tools/ping.py +++ b/capy_discord/exts/tools/ping.py @@ -20,7 +20,7 @@ async def ping(self, interaction: discord.Interaction) -> None: latency = round(capy_discord.instance.latency * 1000) # in ms message = f"Pong! {latency} ms Latency!" embed = discord.Embed(title="Ping", description=message) - self.logger.info(f"/ping invoked user: {interaction.user.id} guild: {interaction.guild_id}") + self.logger.info("/ping invoked user: %s guild: %s", interaction.user.id, interaction.guild_id) await interaction.response.send_message(embed=embed) diff --git a/capy_discord/exts/tools/sync.py b/capy_discord/exts/tools/sync.py index 70d187c..75f9a27 100644 --- a/capy_discord/exts/tools/sync.py +++ b/capy_discord/exts/tools/sync.py @@ -24,7 +24,7 @@ def __init__(self) -> None: async def _sync_commands(self) -> list[discord.app_commands.AppCommand]: """Synchronize commands with Discord.""" synced_commands: list[discord.app_commands.AppCommand] = await capy_discord.instance.tree.sync() - self.logger.info(f"_sync_commands internal: {synced_commands}") + self.logger.info("_sync_commands internal: %s", synced_commands) return synced_commands @commands.command(name="sync", hidden=True) @@ -34,7 +34,7 @@ async def sync(self, ctx: commands.Context[commands.Bot]) -> None: synced = await self._sync_commands() description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}" - self.logger.info(f"!sync invoked user: {ctx.author.id} guild: {ctx.guild.id}") + self.logger.info("!sync invoked user: %s guild: %s", ctx.author.id, ctx.guild.id) await ctx.send(description) except Exception: @@ -47,7 +47,7 @@ async def sync_slash(self, interaction: discord.Interaction) -> None: try: synced = await self._sync_commands() description = f"Synced {len(synced)} commands: {[cmd.name for cmd in synced]}" - self.logger.info(f"/sync invoked user: {interaction.user.id} guild: {interaction.guild_id}") + self.logger.info("/sync invoked user: %s guild: %s", interaction.user.id, interaction.guild_id) await interaction.response.send_message(description) except Exception: diff --git a/capy_discord/logging.py b/capy_discord/logging.py index 224427c..0741857 100644 --- a/capy_discord/logging.py +++ b/capy_discord/logging.py @@ -1,5 +1,7 @@ import logging import sys +from datetime import UTC, datetime +from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Final @@ -51,22 +53,37 @@ def get_logger(name: str) -> logging.Logger: return logging.getLogger(name) -def setup_logging(level: int = logging.INFO) -> None: +def setup_logging(level: int | str = logging.INFO) -> None: """Set up the logging configuration with the specified level.""" root_logger = logging.getLogger() root_logger.setLevel(level) # Create a handler that writes to stdout - handler = logging.StreamHandler(sys.stdout) - formatter = ColoredFormatter(LOG_FORMAT, datefmt=DATE_FORMAT) - handler.setFormatter(formatter) + stream_handler = logging.StreamHandler(sys.stdout) + colored_formatter = ColoredFormatter(LOG_FORMAT, datefmt=DATE_FORMAT) + stream_handler.setFormatter(colored_formatter) # Create a log directory if it doesn't exist log_dir = Path("logs") log_dir.mkdir(exist_ok=True) + # Create a timestamped filename + timestamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S") + log_filename = log_dir / f"capy-discord_{timestamp}.log" + + # Create a handler that writes to a file with rotation + file_handler = RotatingFileHandler( + filename=log_filename, + maxBytes=5 * 1024 * 1024, # 5 MB + backupCount=5, + encoding="utf-8", + ) + file_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT) + file_handler.setFormatter(file_formatter) + # Removing previous handlers to avoid duplicate logs from discord after setup_logging invokation if root_logger.hasHandlers(): root_logger.handlers.clear() - root_logger.addHandler(handler) + root_logger.addHandler(stream_handler) + root_logger.addHandler(file_handler) From 5bc03adbe4a7703689b1deb0fce7ff4dfe8b1142 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Mon, 12 Jan 2026 22:47:56 -0500 Subject: [PATCH 3/4] sourcery fix Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- capy_discord/logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capy_discord/logging.py b/capy_discord/logging.py index 0741857..9c17008 100644 --- a/capy_discord/logging.py +++ b/capy_discord/logging.py @@ -81,7 +81,7 @@ def setup_logging(level: int | str = logging.INFO) -> None: file_formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT) file_handler.setFormatter(file_formatter) - # Removing previous handlers to avoid duplicate logs from discord after setup_logging invokation + # Removing previous handlers to avoid duplicate logs from discord after setup_logging invocation if root_logger.hasHandlers(): root_logger.handlers.clear() From 89e12236dc4b1ec694231ed1b4cd5393732c73d6 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Mon, 12 Jan 2026 23:05:46 -0500 Subject: [PATCH 4/4] [ci] - changed exit code for running test --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 78140c7..6c0dfd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,8 @@ lint = { cmd = "pre-commit install && pre-commit run --all-files", help = "Insta build = { cmd = "docker build -t capy-discord .", help = "Builds the application's Docker image." } run = { cmd = "docker run -it --rm capy-discord", help = "Runs the application inside a new Docker container." } dev = { cmd = "docker run -it --rm -v ./bot:/app/bot capy-discord", help = "Runs the app in Docker with local code mounted for live changes." } -test = { cmd = "pytest -n auto --ff", help = "Runs all tests, starting with previously failed ones." } -retest = { cmd = "pytest -n auto --lf", help = "Reruns only the tests that failed during the last run." } +test = { cmd = "pytest -n auto --ff || (code=$?; if [ $code -eq 5 ]; then exit 0; else exit $code; fi)", help = "Runs all tests, starting with previously failed ones." } +retest = { cmd = "pytest -n auto --lf || (code=$?; if [ $code -eq 5 ]; then exit 0; else exit $code; fi)", help = "Reruns only the tests that failed during the last run." } testcov = { cmd = "pytest -n auto --cov=bot --cov-report= && coverage report", help = "Runs tests, generates coverage data, and fails if coverage is below 80%." } commit = { cmd = "pre-commit install && git add . && git commit -m", help = "Stages all changes and commits with message." }