1 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
3 changed files with 51 additions and 64 deletions
+11 -7
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.5 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.5" 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.4` 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
@@ -73,8 +73,9 @@ await bot.log.debug("noisy", log_to_file=False) # -> Discord only
## Errors ## Errors
Resolution failures (unresolvable guild/channel, bad config, a non-text channel — whether Resolution failures (unresolvable guild/channel, bad config, a non-text channel — whether
passed as an id or as an already-resolved object) raise `ValueError` from `initialize()` passed as an id or as an already-resolved object — or a `timezone` that isn't a `tzinfo`
a misconfigured logger should fail loudly at setup. Underlying `discord` exceptions instance) raise `ValueError` from `initialize()` — a misconfigured logger should fail
loudly at setup. Underlying `discord` exceptions
(`NotFound` / `Forbidden` / `HTTPException` / `InvalidData` and other `ClientException` (`NotFound` / `Forbidden` / `HTTPException` / `InvalidData` and other `ClientException`
subclasses) from `fetch_guild`/`fetch_channel` are normalized to that `ValueError` on every subclasses) from `fetch_guild`/`fetch_channel` are normalized to that `ValueError` on every
resolution path (construction channel, per-guild settings lookup) so callers see one error resolution path (construction channel, per-guild settings lookup) so callers see one error
@@ -87,8 +88,11 @@ doesn't resolve) never breaks the caller's command.
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.5" 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 = [
+39 -56
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,35 +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, a errors: setup raises, sends swallow. resolution failures (unresolvable
non-text channel passed as either an id or an already-resolved object) raise guild/channel, bad config, a non-text channel, an invalid timezone) raise
ValueError from `initialize()` — a misconfigured logger should fail loudly at ValueError from `initialize()`. underlying discord exceptions (NotFound/
setup. underlying discord exceptions (NotFound/Forbidden/HTTPException/ Forbidden/HTTPException/InvalidData and other ClientException subclasses)
InvalidData and other ClientException subclasses) are normalized to that are normalized to that same ValueError. on a per-call send, resolution and
ValueError on every resolution path. on a per-call send, resolution AND send send failures never propagate — they fall back to the stdlib logger so a
failures do NOT propagate: they fall back to the stdlib logger so a transient transient discord failure never breaks the caller's command.
discord failure (or a per-call `guild=` that doesn't resolve) 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__)
@@ -67,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,
@@ -109,24 +106,22 @@ 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 object passed with no guild (e.g. bot.get_channel(id)) would pass # a channel with no guild would otherwise blackout every send silently; derive
# setup silently but then fail every send at _get_guild(None) — swallowed, so # the guild from it, or fail loud here rather than later
# 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): if self.guild is None and isinstance(self.channel, discord.TextChannel):
self.guild = self.channel.guild self.guild = self.channel.guild
if self.guild is None and self.channel is not None: if self.guild is None and self.channel is not None:
raise ValueError("[dpy_logger] channel provided without a resolvable guild") raise ValueError("[dpy_logger] channel provided without a resolvable guild")
# a resolved wrong-type object (Thread/VoiceChannel/ForumChannel) would also pass # a wrong-type channel object (Thread/VoiceChannel/ForumChannel) would also pass
# setup silently — _get_channel only recognizes TextChannel/int, so anything else # silently and misroute/blackhole later; fail loud here instead
# falls through to the bot.settings lookup at send time and misroutes or blackholes
# the sink. fail loud here, mirroring the int-path check in _get_channel.
if self.channel is not None and not isinstance(self.channel, (discord.TextChannel, int)): 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") raise ValueError(f"[dpy_logger] channel {self.channel!r} is not a text channel")
@@ -137,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:
@@ -158,16 +151,12 @@ class DPYLogger:
return self.channel return self.channel
if isinstance(self.channel, int): if isinstance(self.channel, int):
# fetch_channel raises NotFound/Forbidden/HTTPException/InvalidData (a # fetch_channel raises NotFound/Forbidden/HTTPException/InvalidData (a
# ClientException, not an HTTPException); normalize all of those to the # ClientException, not an HTTPException); normalize all to ValueError
# lib's ValueError so a bad construction channel id fails loud with one
# error type, matching the settings path below
try: try:
channel = await guild.fetch_channel(self.channel) channel = await guild.fetch_channel(self.channel)
except (discord.HTTPException, discord.ClientException) as error: except (discord.HTTPException, discord.ClientException) as error:
raise ValueError(f"[dpy_logger] could not fetch channel {self.channel}: {error}") from 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
@@ -178,9 +167,6 @@ class DPYLogger:
try: try:
channel = await guild.fetch_channel(channel_id) channel = await guild.fetch_channel(channel_id)
except (discord.HTTPException, discord.ClientException) as error: except (discord.HTTPException, discord.ClientException) as error:
# fetch_channel raises NotFound/Forbidden/HTTPException/InvalidData (a
# ClientException, not an HTTPException); normalize all of those 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")
@@ -197,15 +183,12 @@ 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 # is-not-None keeps falsy-but-valid values (0, False) rendering
# genuinely absent field (None) or an empty string is dropped/substituted below
if action is not None and str(action) != "": 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 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
# every logging call producing a valid embed
log_value = str(details) if details is not None and str(details) != "" else "(no message)" log_value = str(details) if details is not None and str(details) != "" else "(no message)"
if len(log_value) > 1024: if len(log_value) > 1024:
log_value = log_value[:1021] + "..." log_value = log_value[:1021] + "..."
@@ -220,7 +203,6 @@ 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
raw = (action, str(actor) if actor is not None else None, log_msg) 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) != ""] 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))
@@ -229,10 +211,9 @@ class DPYLogger:
log_to_file=None, content=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
`content` carries the critical-level ping/@here text (None for the other levels), ping/@here text (None for other levels), so every level shares this one contract.
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)
@@ -284,4 +265,6 @@ class DPYLogger:
content = "alert: @here" content = "alert: @here"
else: else:
content = None content = None
if content is not None and len(content) > DISCORD_CONTENT_LIMIT:
content = content[:DISCORD_CONTENT_LIMIT - 3] + "..."
return await self._send("critical", log, action, actor, guild, log_to_file, content) return await self._send("critical", log, action, actor, guild, log_to_file, content)