2 Commits
Author SHA1 Message Date
dsqlandClaude Opus 4.8 7935ea4511 add package: pyproject + src
DPYLogger: leveled discord channel logger (debug/info/success/fail/task/
critical) over discord.py. config-free — embed identity injected at
construction, per-guild channel routing read live from bot.settings.
embed_builder callable + build_embed override for customization. raises
by design; object-only (no module proxy). src/ layout, hatchling build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-23 23:08:35 -04:00
dsql 48de7d7065 init: leveled discord logger
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-23 15:12:03 -04:00
4 changed files with 52 additions and 96 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude # claude
.claude/ CLAUDE.md
# python # python
__pycache__/ __pycache__/
+12 -17
View File
@@ -10,19 +10,17 @@ live from `bot.settings` so it can change at runtime via a command.
`requirements.txt`: `requirements.txt`:
``` ```
dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.4 dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.0
``` ```
Direct: Direct:
```bash ```bash
pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.4" pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.0"
``` ```
Requires `discord.py` (pulled transitively). Requires `discord.py` (pulled transitively).
Drop the `@v0.1.4` suffix from the line above to install the latest unpinned.
## Usage ## Usage
```python ```python
@@ -30,11 +28,11 @@ from dpy_logger import DPYLogger
bot.log = DPYLogger( bot.log = DPYLogger(
bot, guild_id, channel_id, bot, guild_id, channel_id,
colors=log_colors, # optional; merged over sensible defaults colors=cfg.log_colors, # optional; merged over sensible defaults
pings=authorized_devs, # mentioned on critical() pings=cfg.authorized_devs, # mentioned on critical()
timezone=tz, timezone=cfg.timezone,
footer=bot_footer, footer=cfg.bot_footer,
avatar=bot_avatar, avatar=cfg.bot_avatar,
) )
await bot.log.initialize() # resolves ids -> objects, call once await bot.log.initialize() # resolves ids -> objects, call once
@@ -72,13 +70,10 @@ await bot.log.debug("noisy", log_to_file=False) # -> Discord only
## Errors ## Errors
Resolution failures (unresolvable guild/channel, bad config, a non-text channel) raise Resolution failures (unresolvable guild/channel, bad config) raise `ValueError` at
`ValueError` from `initialize()` — a misconfigured logger should fail loudly at setup. `initialize`/call time — a misconfigured logger should fail loudly at setup. Per-call
Underlying `discord` exceptions (`NotFound` / `Forbidden` / `HTTPException`) from **send** failures do **not** propagate: they fall back to the stdlib logger so a
`fetch_guild`/`fetch_channel` are normalized to that `ValueError` so callers see one transient Discord failure never breaks the caller's command.
error type. On a **per-call** send, neither resolution nor send failures propagate: they
fall back to the stdlib logger so a transient Discord failure (or a per-call `guild=`
that doesn't resolve) never breaks the caller's command.
## Construction contract ## Construction contract
@@ -141,4 +136,4 @@ class FeedLogger(DPYLogger):
## Versioning ## Versioning
Releases are tagged `vX.Y.Z`. The install line above pins a release; drop the `@vX.Y.Z` suffix to install the latest unpinned. Pin deliberately for reproducible installs. Tagged `vX.Y.Z`. Pin the tag in `requirements.txt`.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dpy_logger" name = "dpy_logger"
version = "0.1.4" version = "0.1.0"
description = "Leveled Discord channel logger for discord.py — config-free, injectable, installable." description = "Leveled Discord channel logger for discord.py — config-free, injectable, installable."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+35 -74
View File
@@ -12,11 +12,11 @@ command that mutates bot.settings changes routing without a restart.
bot.log = DPYLogger( bot.log = DPYLogger(
bot, guild_id, channel_id, bot, guild_id, channel_id,
colors=log_colors, # optional, falls back to defaults colors=cfg.log_colors, # optional, falls back to defaults
pings=authorized_devs, # mentioned on critical() pings=cfg.authorized_devs, # mentioned on critical()
timezone=tz, timezone=cfg.timezone,
footer=bot_footer, footer=cfg.bot_footer,
avatar=bot_avatar, avatar=cfg.bot_avatar,
) )
await bot.log.initialize() # resolves ids -> objects await bot.log.initialize() # resolves ids -> objects
await bot.log.success("user promoted", action="promote", actor=ctx.author) await bot.log.success("user promoted", action="promote", actor=ctx.author)
@@ -41,10 +41,9 @@ extending: this base has no feed/announcement method by design. a project
that wants one subclasses DPYLogger and adds it, reusing _resolve. that wants one subclasses DPYLogger and adds it, reusing _resolve.
errors: resolution failures (unresolvable guild/channel, bad config) raise errors: resolution failures (unresolvable guild/channel, bad config) raise
ValueError from `initialize()` — a misconfigured logger should fail loudly at ValueError at initialize/call time — a misconfigured logger should fail loudly.
setup. on a per-call send, resolution AND send failures do NOT propagate: they per-call send failures do NOT propagate: they fall back to the stdlib logger so
fall back to the stdlib logger so a transient discord failure (or a per-call a transient discord failure never breaks the caller's command.
`guild=` that doesn't resolve) never breaks the caller's command.
""" """
import logging import logging
@@ -111,29 +110,15 @@ class DPYLogger:
if not self.guild: if not self.guild:
raise ValueError(f"[dpy_logger] cannot resolve channel {self.channel} without a guild") raise ValueError(f"[dpy_logger] cannot resolve channel {self.channel} without a guild")
self.channel = await self._get_channel(self.guild) self.channel = await self._get_channel(self.guild)
# a channel object passed with no guild (e.g. bot.get_channel(id)) would pass
# setup silently but then fail every send at _get_guild(None) — swallowed, so
# nothing reaches discord. derive the guild from the channel it already carries,
# or fail loud at setup (matching the int-channel guard above) rather than later.
if self.guild is None and isinstance(self.channel, discord.TextChannel):
self.guild = self.channel.guild
if self.guild is None and self.channel is not None:
raise ValueError("[dpy_logger] channel provided without a resolvable guild")
async def _get_guild(self, guild): async def _get_guild(self, guild):
"""resolve a guild from id-or-object, raising if unresolvable""" """resolve a guild from id-or-object, raising if unresolvable"""
if guild: if guild:
if isinstance(guild, int): if isinstance(guild, int):
resolved = self.bot.get_guild(guild) resolved = self.bot.get_guild(guild) or await self.bot.fetch_guild(guild)
if resolved is not None: if not resolved:
raise ValueError(f"[dpy_logger] failed to fetch guild {guild}")
return resolved return resolved
# fetch_guild never returns None — it raises NotFound/Forbidden/
# HTTPException; normalize those to the lib's ValueError so callers see
# one error type at setup
try:
return await self.bot.fetch_guild(guild)
except discord.HTTPException as error:
raise ValueError(f"[dpy_logger] failed to fetch guild {guild}: {error}") from error
if isinstance(guild, discord.Guild): if isinstance(guild, discord.Guild):
return guild return guild
raise ValueError("[dpy_logger] no guild available for logging") raise ValueError("[dpy_logger] no guild available for logging")
@@ -143,30 +128,20 @@ class DPYLogger:
if not guild: if not guild:
raise ValueError("[dpy_logger] cannot resolve channel without a guild") raise ValueError("[dpy_logger] cannot resolve channel without a guild")
if not override and getattr(guild, "id", guild) == getattr(self.guild, "id", self.guild): if not override and guild == self.guild:
if isinstance(self.channel, discord.TextChannel): if isinstance(self.channel, discord.TextChannel):
return self.channel return self.channel
if isinstance(self.channel, int): if isinstance(self.channel, int):
channel = await guild.fetch_channel(self.channel) return await guild.fetch_channel(self.channel)
if not isinstance(channel, discord.TextChannel):
# fetch_channel can return a Voice/Category/Forum channel; fail loud
# at setup like the settings path, not later via an AttributeError on .send
raise ValueError(f"[dpy_logger] channel {self.channel} is not a text channel")
return channel
try: try:
channel_id = self.bot.settings[guild.id]["channels"]["logs"] channel_id = self.bot.settings[guild.id]["channels"]["logs"]
except KeyError:
raise ValueError(f"[dpy_logger] no log channel configured for guild {guild.id}")
try:
channel = await guild.fetch_channel(channel_id) channel = await guild.fetch_channel(channel_id)
except discord.HTTPException as error:
# fetch_channel raises NotFound/Forbidden/HTTPException; normalize to the
# lib's ValueError so a bad configured id fails loud with one error type
raise ValueError(f"[dpy_logger] could not fetch channel {channel_id}: {error}") from error
if not isinstance(channel, discord.TextChannel): if not isinstance(channel, discord.TextChannel):
raise ValueError(f"[dpy_logger] configured channel {channel_id} is not a text channel") raise ValueError(f"[dpy_logger] configured channel {channel_id} is not a text channel")
return channel return channel
except KeyError:
raise ValueError(f"[dpy_logger] no log channel configured for guild {guild.id}")
def build_embed(self, level, action, actor, details): def build_embed(self, level, action, actor, details):
"""build the embed for a log call """build the embed for a log call
@@ -179,19 +154,11 @@ class DPYLogger:
if self._embed_builder is not None: if self._embed_builder is not None:
return self._embed_builder(self, level, action, actor, details) return self._embed_builder(self, level, action, actor, details)
em = discord.Embed(color=self.colors[level]) em = discord.Embed(color=self.colors[level])
# test `is not None` so a falsy-but-valid value (0, False) still renders; only a if action:
# genuinely absent field (None) or an empty string is dropped/substituted below
if action is not None and str(action) != "":
em.add_field(name="Action", value=f"`{action}`", inline=True) em.add_field(name="Action", value=f"`{action}`", inline=True)
if actor is not None and str(actor) != "": if actor:
em.add_field(name="Actor", value=f"`{actor}`", inline=True) em.add_field(name="Actor", value=f"`{actor}`", inline=True)
# discord rejects an empty field value (50035) and truncates nothing itself, so em.add_field(name="Log", value=details, inline=False)
# an empty or >1024-char message would 400 the send; substitute + cap to keep
# every logging call producing a valid embed
log_value = str(details) if details is not None and str(details) != "" else "(no message)"
if len(log_value) > 1024:
log_value = log_value[:1021] + "..."
em.add_field(name="Log", value=log_value, inline=False)
em.timestamp = datetime.now(self.timezone) em.timestamp = datetime.now(self.timezone)
em.set_footer(text=f"{self.footer} Logging".strip(), icon_url=self.avatar) em.set_footer(text=f"{self.footer} Logging".strip(), icon_url=self.avatar)
return em return em
@@ -202,28 +169,20 @@ class DPYLogger:
def _emit_stdlib(self, level, action, actor, log_msg): def _emit_stdlib(self, level, action, actor, log_msg):
"""mirror the log line to the stdlib logger (routed to file/console by the app)""" """mirror the log line to the stdlib logger (routed to file/console by the app)"""
# render falsy-but-valid parts (0, False); drop only genuinely-absent (None) ones parts = [p for p in (action, str(actor) if actor else None, log_msg) if p]
raw = (action, str(actor) if actor is not None else None, log_msg)
parts = [str(p) for p in raw if p is not None and str(p) != ""]
_log.log(LEVEL_MAP.get(level, logging.INFO), f"[{level}] " + " | ".join(parts)) _log.log(LEVEL_MAP.get(level, logging.INFO), f"[{level}] " + " | ".join(parts))
async def _send(self, level, log_msg, action=None, actor=None, guild=None, async def _send(self, level, log_msg, action=None, actor=None, guild=None, log_to_file=None):
log_to_file=None, content=None):
"""resolve channel and dispatch a leveled embed; mirror to stdlib unless opted out """resolve channel and dispatch a leveled embed; mirror to stdlib unless opted out
stdlib emit happens first so the record survives even if the discord send fails. stdlib emit happens first so the record survives even if the discord send fails.
a failed send falls back to stdlib rather than propagating into the caller. a failed send falls back to stdlib rather than propagating into the caller.
`content` carries the critical-level ping/@here text (None for the other levels),
so every level shares this one send/error contract.
""" """
if self.log_to_file if log_to_file is None else log_to_file: if self.log_to_file if log_to_file is None else log_to_file:
self._emit_stdlib(level, action, actor, log_msg) self._emit_stdlib(level, action, actor, log_msg)
try: try:
channel = await self._resolve(guild) channel = await self._resolve(guild)
return await channel.send( return await channel.send(embed=self.build_embed(level, action, actor, log_msg))
content=content,
embed=self.build_embed(level, action, actor, log_msg),
)
except Exception: except Exception:
_log.exception(f"[dpy_logger] failed to send {level} log: {log_msg}") _log.exception(f"[dpy_logger] failed to send {level} log: {log_msg}")
return None return None
@@ -246,24 +205,26 @@ class DPYLogger:
failure = fail failure = fail
async def task(self, log, action=None, actor=None, guild=None, log_to_file=None): async def task(self, log, action=None, guild=None, log_to_file=None):
"""log a task-level message with SYSTEM/TASK as the actor """log a task-level message with SYSTEM/TASK as the actor"""
actor is accepted for caller compatibility and ignored — task actions are
always attributed to SYSTEM/TASK regardless of the caller-supplied actor
"""
return await self._send("task", log, action, "SYSTEM/TASK", guild, log_to_file) return await self._send("task", log, action, "SYSTEM/TASK", guild, log_to_file)
async def critical(self, log, action=None, actor=None, guild=None, log_to_file=None): async def critical(self, log, action=None, actor=None, guild=None, log_to_file=None):
"""log a critical-level message and ping configured devs """log a critical-level message and ping configured devs"""
if self.log_to_file if log_to_file is None else log_to_file:
delegates to _send with the ping/@here content so the send + error contract has a self._emit_stdlib("critical", action, actor, log)
single source of truth; critical only adds the alert `content`.
"""
if self.pings: if self.pings:
content = "alert: " + " ".join(f"<@{u}>" for u in self.pings) content = "alert: " + " ".join(f"<@{u}>" for u in self.pings)
elif self.alert_here: elif self.alert_here:
content = "alert: @here" content = "alert: @here"
else: else:
content = None content = None
return await self._send("critical", log, action, actor, guild, log_to_file, content) try:
channel = await self._resolve(guild)
return await channel.send(
content=content,
embed=self.build_embed("critical", action, actor, log),
)
except Exception:
_log.exception(f"[dpy_logger] failed to send critical log: {log}")
return None