9 Commits
Author SHA1 Message Date
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
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
4 changed files with 81 additions and 77 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.4 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.4" 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
@@ -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.4" version = "1.0.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__"]
+54 -63
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,35 +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) raise
ValueError from `initialize()` — a misconfigured logger should fail loudly at
setup. 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__)
@@ -63,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,
@@ -105,16 +98,16 @@ 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:
@@ -127,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:
@@ -147,27 +138,37 @@ 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):
channel = await guild.fetch_channel(self.channel) # fetch_channel raises NotFound/Forbidden/HTTPException/InvalidData (a
# ClientException, not an HTTPException); normalize all to ValueError
try:
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
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 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")
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
@@ -179,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
@@ -202,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:
@@ -249,7 +238,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)
@@ -266,4 +255,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)