14 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
dsql bfabd1c31d chore: ignore .claude/ dir (CLAUDE.md now lives under .claude/)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:55:13 -04:00
dsql 3cb741f668 fix: DL-1 validate the int-path channel is a TextChannel; normalize fetch errors
the construction-channel int path now applies the same isinstance(TextChannel) check the
settings path enforces, so a Voice/Category/Forum channel fails loud at setup instead of
AttributeError-ing on .send later. settings-path fetch_channel discord exceptions
normalize to ValueError; README error contract updated to match.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:35:00 -04:00
dsql 7b98214c68 docs: pin install line to release, note unpinned-latest option
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:13:50 -04:00
dsql a633138e4e docs: show unpinned install line; note tag-pinning for reproducibility
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:07:35 -04:00
dsql 3a580e8239 fix: normalize fetch_guild errors; guard embed Log field value (v0.1.2)
- _get_guild catches discord.HTTPException (NotFound/Forbidden/HTTPException) from
  fetch_guild and re-raises the lib's ValueError; the old 'if not resolved' branch was
  unreachable since fetch_guild never returns None (L10)
- build_embed substitutes '(no message)' for empty details and truncates to 1024, so a
  logging call never 400s the embed send on an empty/over-long message (L11).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 17:58:09 -04:00
dsql e20c7bbda3 fix: restore task() actor kwarg (accept-and-ignore) for caller compatibility
the original task() accepted an actor kwarg and ignored it (task actions are always attributed to SYSTEM/TASK); the rewrite dropped the param, so live callers passing task(..., actor=...) hit a TypeError. added actor=None back, accept-and-ignore, behavior unchanged. bump to v0.1.1. (feed() stays out of the base by design — consumers subclass.)

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 03:25:16 -04:00
dsql f480a7c077 docs: correct README error contract (initialize-time, raw discord exceptions)
the README Errors section claimed resolution raises ValueError 'at call time', but per-call resolution is swallowed with send failures and the raise happens at initialize() — and the raised type can be an underlying discord exception (NotFound/Forbidden/HTTPException), not only ValueError. corrected to match the code and the module docstring.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 01:39:21 -04:00
dsql dd56009add docs: clarify resolution raises at initialize(), per-call resolution is swallowed
the module docstring said resolution failures raise 'at initialize/call time', but per-call send-path resolution failures are swallowed with send failures (the documented intentional swallow). corrected to state resolution raises from initialize() at setup, while a per-call guild= that doesn't resolve falls back to stdlib like any send failure.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 01:10:25 -04:00
dsql 988464ef12 fix: compare guild by id so an uninitialized int-guild routes correctly
_get_channel compared guild == self.guild; when initialize() was not called, self.guild stays an int while the passed guild is a Guild object, so the comparison was always False and routing silently fell through to the bot.settings lookup instead of using the constructed channel. now compares by .id on both sides.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:46:20 -04:00
dsql 7a5684e065 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.
dual-sink: mirrors every call to the stdlib logger. embed_builder
callable + build_embed override for customization. raises by design;
object-only (no module proxy). src/ layout, hatchling build.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-24 21:36:43 -04:00
5 changed files with 309 additions and 10 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude
CLAUDE.md
.claude/
# python
__pycache__/
+20 -9
View File
@@ -10,17 +10,19 @@ live from `bot.settings` so it can change at runtime via a command.
`requirements.txt`:
```
dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.0
dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.6
```
Direct:
```bash
pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.0"
pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.6"
```
Requires `discord.py` (pulled transitively).
Drop the `@v0.1.6` suffix from the line above to install the latest unpinned.
## Usage
```python
@@ -70,18 +72,27 @@ await bot.log.debug("noisy", log_to_file=False) # -> Discord only
## Errors
Resolution failures (unresolvable guild/channel, bad config) raise `ValueError` at
`initialize`/call time — a misconfigured logger should fail loudly at setup. Per-call
**send** failures do **not** propagate: they fall back to the stdlib logger so a
transient Discord failure never breaks the caller's command.
Resolution failures (unresolvable guild/channel, bad config, a non-text channel — whether
passed as an id or as an already-resolved object — or a `timezone` that isn't a `tzinfo`
instance) raise `ValueError` from `initialize()` — a misconfigured logger should fail
loudly at setup. Underlying `discord` exceptions
(`NotFound` / `Forbidden` / `HTTPException` / `InvalidData` and other `ClientException`
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
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
The host injects everything; the lib never imports `config`:
- `colors` (dict, optional) — per-level colors, merged over defaults
- `pings` (list of user ids) — mentioned on `critical()`
- `timezone`, `footer`, `avatar` — embed identity
- `pings` (list of user ids) — mentioned on `critical()`; the ping content is capped at
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
`@here` only when this is `True`; otherwise it sends no mention
- `embed_builder` (callable, optional) — restyle embeds without subclassing
@@ -136,4 +147,4 @@ class FeedLogger(DPYLogger):
## Versioning
Tagged `vX.Y.Z`. Pin the tag in `requirements.txt`.
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.
+15
View File
@@ -0,0 +1,15 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "dpy_logger"
version = "0.1.6"
description = "Leveled Discord channel logger for discord.py — config-free, injectable, installable."
requires-python = ">=3.10"
dependencies = [
"discord.py>=2.0",
]
[tool.hatch.build.targets.wheel]
packages = ["src/dpy_logger"]
+3
View File
@@ -0,0 +1,3 @@
from .dpy_logger import DPYLogger, DEFAULT_COLORS
__all__ = ["DPYLogger", "DEFAULT_COLORS"]
+270
View File
@@ -0,0 +1,270 @@
"""
discord channel logger
leveled logging to a discord channel via embeds. attach to your bot (e.g.
bot.log) and call bot.log.info(...), bot.log.critical(...), etc. config-free:
static identity is injected at construction; per-guild channel routing is
read live from bot.settings at call time (no restart needed).
from dpy_logger import DPYLogger
bot.log = DPYLogger(
bot, guild_id, channel_id,
colors=log_colors, # optional, falls back to defaults
pings=authorized_devs, # mentioned on critical()
timezone=tz,
footer=bot_footer,
avatar=bot_avatar,
)
await bot.log.initialize() # resolves ids -> objects
await bot.log.success("user promoted", action="promote", actor=ctx.author)
levels: debug, info, success, fail (alias failure), task, critical.
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
construction channel.
custom embeds: pass embed_builder=fn(logger, level, action, actor, details)
-> discord.Embed to restyle without subclassing; every level routes through
it. for complex cases override build_embed in a subclass instead.
dual sink: every call also mirrors to the stdlib logger (getLogger(__name__));
the stdlib emit happens before the discord send, so the record survives even
if discord fails. set log_to_file=False at construction, or per call, to
disable.
extending: no feed/announcement method by design; subclass and reuse
_resolve for project-specific log types.
errors: setup raises, sends swallow. resolution failures (unresolvable
guild/channel, bad config, a non-text channel, an invalid timezone) raise
ValueError from `initialize()`. underlying discord exceptions (NotFound/
Forbidden/HTTPException/InvalidData and other ClientException subclasses)
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 discord
from datetime import datetime, tzinfo
from typing import Callable, Optional
_log = logging.getLogger(__name__)
DEFAULT_COLORS = {
"debug": 0x808080,
"info": 0x709288,
"success": 0x00E27D,
"fail": 0xD93025,
"task": 0x5AC6E1,
"critical": 0x000000,
}
DISCORD_CONTENT_LIMIT = 2000
LEVEL_MAP = {
"debug": logging.DEBUG,
"info": logging.INFO,
"success": logging.INFO,
"task": logging.INFO,
"fail": logging.ERROR,
"critical": logging.CRITICAL,
}
class DPYLogger:
"""leveled discord channel logger; attach to the bot as bot.log"""
def __init__(
self,
bot,
guild=None,
channel=None,
*,
colors: Optional[dict] = None,
pings: Optional[list] = None,
timezone: Optional[object] = None,
footer: str = "",
avatar: Optional[str] = None,
alert_here: bool = False,
embed_builder: Optional[Callable] = None,
log_to_file: bool = True,
):
self.bot = bot
self.guild = guild
self.channel = channel
self.pings = pings or []
self.timezone = timezone
self.footer = footer
self.avatar = avatar
self.alert_here = alert_here
self.colors = {**DEFAULT_COLORS, **(colors or {})}
self._embed_builder = embed_builder
self.log_to_file = log_to_file
async def initialize(self):
"""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):
self.guild = await self._get_guild(self.guild)
if isinstance(self.channel, int):
if not self.guild:
raise ValueError(f"[dpy_logger] cannot resolve channel {self.channel} without a 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):
"""resolve a guild from id-or-object, raising if unresolvable"""
if guild:
if isinstance(guild, int):
resolved = self.bot.get_guild(guild)
if resolved is not None:
return resolved
# fetch_guild raises rather than returning None; normalize to ValueError
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):
return guild
raise ValueError("[dpy_logger] no guild available for logging")
async def _get_channel(self, guild, override=False):
"""resolve the log channel, preferring self.channel when the guild matches"""
if not 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 isinstance(self.channel, discord.TextChannel):
return self.channel
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)
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):
raise ValueError(f"[dpy_logger] channel {self.channel} is not a text channel")
return channel
try:
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)
except (discord.HTTPException, discord.ClientException) as error:
raise ValueError(f"[dpy_logger] could not fetch channel {channel_id}: {error}") from error
if not isinstance(channel, discord.TextChannel):
raise ValueError(f"[dpy_logger] configured channel {channel_id} is not a text channel")
return channel
def build_embed(self, level, action, actor, details):
"""build the embed for a log call
if an `embed_builder` callable was passed at construction, it is used:
embed_builder(logger, level, action, actor, details) -> discord.Embed
otherwise the default structure is built. subclasses may also override
this method directly instead of passing a callable.
"""
if self._embed_builder is not None:
return self._embed_builder(self, level, action, actor, details)
em = discord.Embed(color=self.colors[level])
# 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)
if actor is not None and str(actor) != "":
em.add_field(name="Actor", value=f"`{actor}`", inline=True)
# discord 400s an empty or >1024-char field value; substitute + cap
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.set_footer(text=f"{self.footer} Logging".strip(), icon_url=self.avatar)
return em
async def _resolve(self, guild):
"""resolve the target channel for a call, honoring a per-call guild override"""
return await self._get_channel(await self._get_guild(guild or self.guild), bool(guild))
def _emit_stdlib(self, level, action, actor, log_msg):
"""mirror the log line to the stdlib logger (routed to file/console by the app)"""
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))
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
stdlib emit happens first so the record survives a failed discord send, which
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:
self._emit_stdlib(level, action, actor, log_msg)
try:
channel = await self._resolve(guild)
return await channel.send(
content=content,
embed=self.build_embed(level, action, actor, log_msg),
)
except Exception:
_log.exception(f"[dpy_logger] failed to send {level} log: {log_msg}")
return None
async def debug(self, log, action=None, actor=None, guild=None, log_to_file=None):
"""log a debug-level message"""
return await self._send("debug", log, action, actor, guild, log_to_file)
async def info(self, log, action=None, actor=None, guild=None, log_to_file=None):
"""log an info-level message"""
return await self._send("info", log, action, actor, guild, log_to_file)
async def success(self, log, action=None, actor=None, guild=None, log_to_file=None):
"""log a success-level message"""
return await self._send("success", log, action, actor, guild, log_to_file)
async def fail(self, log, action=None, actor=None, guild=None, log_to_file=None):
"""log a fail-level message"""
return await self._send("fail", log, action, actor, guild, log_to_file)
failure = fail
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
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)
async def critical(self, log, action=None, actor=None, guild=None, log_to_file=None):
"""log a critical-level message and ping configured devs
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:
content = "alert: " + " ".join(f"<@{u}>" for u in self.pings)
elif self.alert_here:
content = "alert: @here"
else:
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)