4 Commits
Author SHA1 Message Date
dsql 1c5d42e4f0 fix: validate timezone at setup; cap critical() ping content (dpylogger-10/11)
a string timezone passed setup silently and TypeError'd on every send,
blackholing the sink; now validated as a real tzinfo in initialize().
a long pings list could push critical() content past Discord's 2000-char
limit, silently dropping the send; content is now capped to fit. also
compresses docstrings/comments with no behavior change.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 23:23:55 -04:00
dsql 733306574f fix: normalize construction-channel fetch errors; reject wrong-type channel objects at setup (dpylogger-7/8)
initialize() previously let a bad construction channel id leak raw
discord.errors.NotFound/Forbidden/HTTPException past the documented
ValueError-only setup contract, and let an already-resolved non-TextChannel
object (Thread/VoiceChannel/ForumChannel) pass silently, later misrouting or
blackholing every send. Wrap the construction-channel fetch in the same
try/except as the settings path and type-check the resolved-object channel
path, mirroring the existing int-path guard.

Widened the normalization tuple to also catch discord.ClientException
(covers InvalidData), fixing dpylogger-9 opportunistically since it's the
same pattern.

Bump to v0.1.5.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 17:19:03 -04:00
dsql 1ea1cb5b63 fix: falsy details/actor render + critical() delegates to _send (dpylogger-2/3)
- dpylogger-2: build_embed/_emit_stdlib test 'is not None' so falsy-but-valid values (0,
  False) render; only None or '' is dropped/substituted.
- dpylogger-3: critical() delegates to _send (via a new content= param) instead of
  hand-mirroring the send/error scaffolding — single source of truth.
verified vs discord.py 2.7.1. bump v0.1.2 -> v0.1.4

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-01 00:28:36 -04:00
dsql 43f82d2774 fix: channel object without a guild derives the guild instead of silently dropping (dpylogger-1)
DPYLogger(guild=None, channel=<TextChannel>) passed initialize() (both int-guards skipped)
but then every send failed at _get_guild(None) and got swallowed — logs never reached
discord, no loud setup signal. now derive self.guild from the channel it already carries,
or raise loud at setup for a truly guild-less channel (mirroring the int-channel guard).
verified vs discord.py 2.7.1. bump v0.1.2 -> v0.1.3

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-30 21:04:51 -04:00
3 changed files with 87 additions and 68 deletions
+18 -12
View File
@@ -10,18 +10,18 @@ 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.2 dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.6
``` ```
Direct: Direct:
```bash ```bash
pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.2" pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.6"
``` ```
Requires `discord.py` (pulled transitively). Requires `discord.py` (pulled transitively).
Drop the `@v0.1.2` suffix from the line above to install the latest unpinned. Drop the `@v0.1.6` suffix from the line above to install the latest unpinned.
## Usage ## Usage
@@ -72,21 +72,27 @@ 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, a non-text channel — whether
`ValueError` from `initialize()` — a misconfigured logger should fail loudly at setup. passed as an id or as an already-resolved object — or a `timezone` that isn't a `tzinfo`
Underlying `discord` exceptions (`NotFound` / `Forbidden` / `HTTPException`) from instance) raise `ValueError` from `initialize()` — a misconfigured logger should fail
`fetch_guild`/`fetch_channel` are normalized to that `ValueError` so callers see one loudly at setup. Underlying `discord` exceptions
error type. On a **per-call** send, neither resolution nor send failures propagate: they (`NotFound` / `Forbidden` / `HTTPException` / `InvalidData` and other `ClientException`
fall back to the stdlib logger so a transient Discord failure (or a per-call `guild=` subclasses) from `fetch_guild`/`fetch_channel` are normalized to that `ValueError` on every
that doesn't resolve) never breaks the caller's command. resolution path (construction channel, per-guild settings lookup) so callers see one 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
The host injects everything; the lib never imports `config`: The host injects everything; the lib never imports `config`:
- `colors` (dict, optional) — per-level colors, merged over defaults - `colors` (dict, optional) — per-level colors, merged over defaults
- `pings` (list of user ids) — mentioned on `critical()` - `pings` (list of user ids) — mentioned on `critical()`; the ping content is capped at
- `timezone`, `footer`, `avatar` — embed identity Discord's 2000-char message limit so a long list still sends
- `timezone` (`tzinfo`, optional) — must be a real `tzinfo` instance (e.g. `datetime.timezone.utc`);
validated at `initialize()`, not a string
- `footer`, `avatar` — embed identity
- `alert_here` (bool) — if no `pings` are set, `critical()` falls back to - `alert_here` (bool) — if no `pings` are set, `critical()` falls back to
`@here` only when this is `True`; otherwise it sends no mention `@here` only when this is `True`; otherwise it sends no mention
- `embed_builder` (callable, optional) — restyle embeds without subclassing - `embed_builder` (callable, optional) — restyle embeds without subclassing
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dpy_logger" name = "dpy_logger"
version = "0.1.2" version = "0.1.6"
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 = [
+67 -54
View File
@@ -1,12 +1,10 @@
""" """
discord channel logger discord channel logger
leveled logging to a discord channel via embeds. attach to your bot leveled logging to a discord channel via embeds. attach to your bot (e.g.
(e.g. bot.log) and call bot.log.info(...), bot.log.critical(...), etc. bot.log) and call bot.log.info(...), bot.log.critical(...), etc. config-free:
static identity is injected at construction; per-guild channel routing is
config-free: all static identity is injected at construction; dynamic read live from bot.settings at call time (no restart needed).
per-guild channel routing is read live from bot.settings at call time, so a
command that mutates bot.settings changes routing without a restart.
from dpy_logger import DPYLogger from dpy_logger import DPYLogger
@@ -25,31 +23,32 @@ levels: debug, info, success, fail (alias failure), task, critical.
dynamic routing: a per-call guild= argument logs to that guild's configured dynamic routing: a per-call guild= argument logs to that guild's configured
channel via bot.settings[guild.id]['channels']['logs']; omit it to use the channel via bot.settings[guild.id]['channels']['logs']; omit it to use the
channel this logger was constructed with. construction channel.
custom embeds: pass embed_builder=fn to restyle without subclassing, where custom embeds: pass embed_builder=fn(logger, level, action, actor, details)
fn(logger, level, action, actor, details) -> discord.Embed. every level -> discord.Embed to restyle without subclassing; every level routes through
(including critical) routes through it. for complex cases override the it. for complex cases override build_embed in a subclass instead.
build_embed method in a subclass instead.
dual sink: every call also mirrors to the stdlib logger (getLogger(__name__)), dual sink: every call also mirrors to the stdlib logger (getLogger(__name__));
which the app routes to file/console. set log_to_file=False at construction to the stdlib emit happens before the discord send, so the record survives even
disable, or pass log_to_file=True/False per call to override. the stdlib emit if discord fails. set log_to_file=False at construction, or per call, to
happens before the discord send, so the record survives even if discord fails. disable.
extending: this base has no feed/announcement method by design. a project extending: no feed/announcement method by design; subclass and reuse
that wants one subclasses DPYLogger and adds it, reusing _resolve. _resolve for project-specific log types.
errors: resolution failures (unresolvable guild/channel, bad config) raise errors: setup raises, sends swallow. resolution failures (unresolvable
ValueError from `initialize()` — a misconfigured logger should fail loudly at guild/channel, bad config, a non-text channel, an invalid timezone) raise
setup. on a per-call send, resolution AND send failures do NOT propagate: they ValueError from `initialize()`. underlying discord exceptions (NotFound/
fall back to the stdlib logger so a transient discord failure (or a per-call Forbidden/HTTPException/InvalidData and other ClientException subclasses)
`guild=` that doesn't resolve) never breaks the caller's command. are normalized to that same ValueError. on a per-call send, resolution and
send failures never propagate — they fall back to the stdlib logger so a
transient discord failure never breaks the caller's command.
""" """
import logging import logging
import discord import discord
from datetime import datetime from datetime import datetime, tzinfo
from typing import Callable, Optional from typing import Callable, Optional
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@@ -63,6 +62,8 @@ DEFAULT_COLORS = {
"critical": 0x000000, "critical": 0x000000,
} }
DISCORD_CONTENT_LIMIT = 2000
LEVEL_MAP = { LEVEL_MAP = {
"debug": logging.DEBUG, "debug": logging.DEBUG,
"info": logging.INFO, "info": logging.INFO,
@@ -105,12 +106,24 @@ class DPYLogger:
async def initialize(self): async def initialize(self):
"""resolve guild/channel from ids to objects; call once after construction""" """resolve guild/channel from ids to objects; call once after construction"""
if self.timezone is not None and not isinstance(self.timezone, tzinfo):
raise ValueError(f"[dpy_logger] timezone must be a tzinfo instance, not {self.timezone!r}")
if isinstance(self.guild, int): if isinstance(self.guild, int):
self.guild = await self._get_guild(self.guild) self.guild = await self._get_guild(self.guild)
if isinstance(self.channel, int): if isinstance(self.channel, int):
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 with no guild would otherwise blackout every send silently; derive
# the guild from it, or fail loud here 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")
# a wrong-type channel object (Thread/VoiceChannel/ForumChannel) would also pass
# silently and misroute/blackhole later; fail loud here instead
if self.channel is not None and not isinstance(self.channel, (discord.TextChannel, int)):
raise ValueError(f"[dpy_logger] channel {self.channel!r} is not a text channel")
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"""
@@ -119,9 +132,7 @@ class DPYLogger:
resolved = self.bot.get_guild(guild) resolved = self.bot.get_guild(guild)
if resolved is not None: if resolved is not None:
return resolved return resolved
# fetch_guild never returns None — it raises NotFound/Forbidden/ # fetch_guild raises rather than returning None; normalize to ValueError
# HTTPException; normalize those to the lib's ValueError so callers see
# one error type at setup
try: try:
return await self.bot.fetch_guild(guild) return await self.bot.fetch_guild(guild)
except discord.HTTPException as error: except discord.HTTPException as error:
@@ -139,10 +150,13 @@ class DPYLogger:
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):
# fetch_channel raises NotFound/Forbidden/HTTPException/InvalidData (a
# ClientException, not an HTTPException); normalize all to ValueError
try:
channel = await guild.fetch_channel(self.channel) channel = await guild.fetch_channel(self.channel)
except (discord.HTTPException, discord.ClientException) as error:
raise ValueError(f"[dpy_logger] could not fetch channel {self.channel}: {error}") from error
if not isinstance(channel, discord.TextChannel): 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") raise ValueError(f"[dpy_logger] channel {self.channel} is not a text channel")
return channel return channel
@@ -152,9 +166,7 @@ class DPYLogger:
raise ValueError(f"[dpy_logger] no log channel configured for guild {guild.id}") raise ValueError(f"[dpy_logger] no log channel configured for guild {guild.id}")
try: try:
channel = await guild.fetch_channel(channel_id) channel = await guild.fetch_channel(channel_id)
except discord.HTTPException as error: except (discord.HTTPException, discord.ClientException) 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 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")
@@ -171,14 +183,13 @@ 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])
if action: # is-not-None keeps falsy-but-valid values (0, False) rendering
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: if actor is not None and str(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 # discord 400s an empty or >1024-char field value; substitute + cap
# an empty or >1024-char message would 400 the send; substitute + cap to keep log_value = str(details) if details is not None and str(details) != "" else "(no message)"
# every logging call producing a valid embed
log_value = str(details) if details else "(no message)"
if len(log_value) > 1024: if len(log_value) > 1024:
log_value = log_value[:1021] + "..." log_value = log_value[:1021] + "..."
em.add_field(name="Log", value=log_value, inline=False) em.add_field(name="Log", value=log_value, inline=False)
@@ -192,20 +203,26 @@ 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)"""
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, log_to_file=None): async def _send(self, level, log_msg, action=None, actor=None, guild=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 a failed discord send, which
a failed send falls back to stdlib rather than propagating into the caller. falls back to stdlib rather than propagating. `content` carries the critical-level
ping/@here text (None for other levels), so every level shares this one 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(embed=self.build_embed(level, action, actor, log_msg)) return await channel.send(
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
@@ -237,21 +254,17 @@ class DPYLogger:
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:
self._emit_stdlib("critical", action, actor, log) delegates to _send with the ping/@here content so the send + error contract has a
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
try: if content is not None and len(content) > DISCORD_CONTENT_LIMIT:
channel = await self._resolve(guild) content = content[:DISCORD_CONTENT_LIMIT - 3] + "..."
return await channel.send( return await self._send("critical", log, action, actor, guild, log_to_file, content)
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