10 Commits
Author SHA1 Message Date
dsql 0e0646a5d6 chore: bump to 1.1.0 (logging-discipline audit)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-10 23:00:50 -04:00
dsql 270d7df9ae fix: discord send failure logs WARNING, not ERROR (D4)
_send mirrors the record to the stdlib sink BEFORE attempting the discord send, so a
swallowed send failure is recovered cleanly - the log survives, only the channel mirror
was lost. that is a WARNING (recovered), not an ERROR. demote from _log.exception (ERROR +
traceback) to _log.warning with the reason folded in lazily; the traceback belonged on a
terminal/unhandled path, and this one recovers via the guaranteed stdlib record.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-09 02:14:11 -04:00
dsql d21de01ada release: 1.0.0
first stable release. pre-1.0.0 verification complete: all surviving MED regressions and
gaps resolved and independently re-fired, tree audited clean across the suite.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-09 18:53:15 -04:00
dsql 02c17c7e8b fix: cap Action/Actor embed values before wrapping in backticks (no unbalanced markup)
_cap_field truncated the already-backtick-wrapped ('`{action}`') string past 1024 chars,
cutting off the closing backtick so an over-limit field rendered as an unbalanced inline-code
span. it now caps the inner value with reserve=2 (for the two backticks) and wraps after, so
the field stays <=1024 and the backticks are always balanced.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 17:17:06 -04:00
dsql 4db2a79e0a fix: cap Action/Actor embed field values to discord's 1024-char limit (dpylogger-8)
The 1024-char cap + empty substitute only ever applied to the Log field.
Action and Actor got no cap, so a long action/actor string (a real path -
e.g. a long command invocation string as the action) triggers Discord's
BASE_TYPE_MAX_LENGTH 400 on send; _send swallows that via log.exception and
returns None, silently losing the Discord message while the stdlib mirror
survives. Route all three fields through one _cap_field helper (single
source for the 1024 constant) instead of duplicating the cap logic.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:11:40 -04:00
dsql 262477d193 fix: chain the KeyError->ValueError normalization with from error (dpylogger-10)
The settings-lookup KeyError->ValueError raise was bare, unlike the three
sibling discord-exception normalization sites which all chain via
"from error". __context__ still linked implicitly at runtime, but this makes
the traceback style consistent (flake8-B904) across every normalization site.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:11:26 -04:00
dsql 8bba9dde05 fix: check channel type before the guild-derivation guard (dpylogger-9)
The wrong-type channel check sat after the no-resolvable-guild guard, so a
non-TextChannel object passed without a guild raised the misleading "channel
provided without a resolvable guild" instead of "is not a text channel" - the
accurate message only fired when a guild was also supplied. Move the type
check first so both branches report the real problem.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:11:05 -04:00
dsql 4e2ba00120 refactor: derive __version__ from package metadata (single source)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:59:09 -04:00
dsql 63d9ed10d3 docs: compress prose/module docstrings, em-dash->hyphen (de-bloat wave 1)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 00:12:44 -04:00
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
4 changed files with 73 additions and 86 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@v1.0.0
``` ```
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@v1.0.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. Drop the `@v1.0.0` 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 = "1.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 = [
+8 -1
View File
@@ -1,3 +1,10 @@
from importlib.metadata import version, PackageNotFoundError
from .dpy_logger import DPYLogger, DEFAULT_COLORS from .dpy_logger import DPYLogger, DEFAULT_COLORS
__all__ = ["DPYLogger", "DEFAULT_COLORS"] try:
__version__ = version("dpy_logger")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
__all__ = ["DPYLogger", "DEFAULT_COLORS", "__version__"]
+53 -77
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
@@ -21,39 +19,27 @@ command that mutates bot.settings changes routing without a restart.
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)
levels: debug, info, success, fail (alias failure), task, critical. levels: debug, info, success, fail (alias failure), task, critical. a per-call
guild= argument routes to that guild's bot.settings[guild.id]['channels']['logs']
instead of the construction channel. pass embed_builder=fn(logger, level, action,
actor, details) -> discord.Embed to restyle without subclassing, or override
build_embed in a subclass for complex cases. no feed/announcement method by
design; subclass and reuse _resolve for project-specific log types.
dynamic routing: a per-call guild= argument logs to that guild's configured dual sink: every call also mirrors to the stdlib logger (getLogger(__name__))
channel via bot.settings[guild.id]['channels']['logs']; omit it to use the before the discord send, so the record survives even if discord fails. set
channel this logger was constructed with. log_to_file=False at construction, or per call, to disable.
custom embeds: pass embed_builder=fn to restyle without subclassing, where errors: setup raises, sends swallow. resolution failures (unresolvable
fn(logger, level, action, actor, details) -> discord.Embed. every level guild/channel, bad config, a non-text channel, an invalid timezone) raise
(including critical) routes through it. for complex cases override the ValueError from `initialize()`, with underlying discord exceptions normalized
build_embed method in a subclass instead. to it. per-call send failures never propagate - they fall back to the stdlib
logger so a transient discord failure never breaks the caller's command.
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
disable, or pass log_to_file=True/False per call to override. the stdlib emit
happens before the discord send, so the record survives even if discord fails.
extending: this base has no feed/announcement method by design. a project
that wants one subclasses DPYLogger and adds it, reusing _resolve.
errors: resolution failures (unresolvable guild/channel, bad config, a
non-text channel passed as either an id or an already-resolved object) raise
ValueError from `initialize()` — a misconfigured logger should fail loudly at
setup. underlying discord exceptions (NotFound/Forbidden/HTTPException/
InvalidData and other ClientException subclasses) are normalized to that
ValueError on every resolution path. on a per-call send, resolution AND send
failures do NOT 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.
""" """
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 +53,9 @@ DEFAULT_COLORS = {
"critical": 0x000000, "critical": 0x000000,
} }
DISCORD_CONTENT_LIMIT = 2000
DISCORD_FIELD_VALUE_LIMIT = 1024
LEVEL_MAP = { LEVEL_MAP = {
"debug": logging.DEBUG, "debug": logging.DEBUG,
"info": logging.INFO, "info": logging.INFO,
@@ -109,26 +98,20 @@ 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 if self.channel is not None and not isinstance(self.channel, (discord.TextChannel, int)):
# setup silently but then fail every send at _get_guild(None) — swallowed, so raise ValueError(f"[dpy_logger] channel {self.channel!r} is not a text channel")
# 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
# setup silently — _get_channel only recognizes TextChannel/int, so anything else
# 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)):
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"""
@@ -137,9 +120,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,34 +139,36 @@ 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
try: try:
channel_id = self.bot.settings[guild.id]["channels"]["logs"] channel_id = self.bot.settings[guild.id]["channels"]["logs"]
except KeyError: except KeyError as error:
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}") from error
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")
return channel return channel
@staticmethod
def _cap_field(value: str, reserve: int = 0) -> str:
"""cap a value to discord's 1024-char field limit (less `reserve` chars for any wrapping
the caller adds around it, e.g. backticks), truncating with an ellipsis"""
limit = DISCORD_FIELD_VALUE_LIMIT - reserve
if len(value) > limit:
return value[:limit - 3] + "..."
return value
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
@@ -197,19 +180,14 @@ 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 # discord 400s an empty or >1024-char field value; cap then wrap so the closing
# genuinely absent field (None) or an empty string is dropped/substituted below # backtick is never truncated off (cap the inner value, accounting for the 2 backticks)
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"`{self._cap_field(str(action), reserve=2)}`", 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"`{self._cap_field(str(actor), reserve=2)}`", inline=True)
# discord rejects an empty field value (50035) and truncates nothing itself, so
# 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: em.add_field(name="Log", value=self._cap_field(log_value), inline=False)
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
@@ -220,20 +198,13 @@ 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))
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, 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.
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:
@@ -242,8 +213,11 @@ class DPYLogger:
content=content, content=content,
embed=self.build_embed(level, action, actor, log_msg), embed=self.build_embed(level, action, actor, log_msg),
) )
except Exception: except Exception as exc:
_log.exception(f"[dpy_logger] failed to send {level} log: {log_msg}") # WARNING, not ERROR: the record was already mirrored to the stdlib sink BEFORE the
# send (see _emit_stdlib above), so a swallowed discord-send failure is recovered
# cleanly - the log survives, only the channel mirror was lost. lazy interpolation.
_log.warning("[dpy_logger] failed to send %s log: %s (%s)", level, log_msg, exc)
return None return None
async def debug(self, log, action=None, actor=None, guild=None, log_to_file=None): async def debug(self, log, action=None, actor=None, guild=None, log_to_file=None):
@@ -267,7 +241,7 @@ class DPYLogger:
async def task(self, log, action=None, actor=None, guild=None, log_to_file=None): async def task(self, log, action=None, actor=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 actor is accepted for caller compatibility and ignored - task actions are
always attributed to SYSTEM/TASK regardless of the caller-supplied actor 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)
@@ -284,4 +258,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)