23 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
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@v1.0.0
```
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@v1.0.0"
```
Requires `discord.py` (pulled transitively).
Drop the `@v1.0.0` 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 = "1.1.0"
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"]
+10
View File
@@ -0,0 +1,10 @@
from importlib.metadata import version, PackageNotFoundError
from .dpy_logger import DPYLogger, DEFAULT_COLORS
try:
__version__ = version("dpy_logger")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
__all__ = ["DPYLogger", "DEFAULT_COLORS", "__version__"]
+263
View File
@@ -0,0 +1,263 @@
"""
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. 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.
dual sink: every call also mirrors to the stdlib logger (getLogger(__name__))
before the discord send, so the record survives even if discord fails. set
log_to_file=False at construction, or per call, to disable.
errors: setup raises, sends swallow. resolution failures (unresolvable
guild/channel, bad config, a non-text channel, an invalid timezone) raise
ValueError from `initialize()`, with underlying discord exceptions normalized
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.
"""
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
DISCORD_FIELD_VALUE_LIMIT = 1024
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)
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")
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")
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 as error:
raise ValueError(f"[dpy_logger] no log channel configured for guild {guild.id}") from error
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
@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):
"""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])
# discord 400s an empty or >1024-char field value; cap then wrap so the closing
# backtick is never truncated off (cap the inner value, accounting for the 2 backticks)
if action is not None and str(action) != "":
em.add_field(name="Action", value=f"`{self._cap_field(str(action), reserve=2)}`", inline=True)
if actor is not None and str(actor) != "":
em.add_field(name="Actor", value=f"`{self._cap_field(str(actor), reserve=2)}`", inline=True)
log_value = str(details) if details is not None and str(details) != "" else "(no message)"
em.add_field(name="Log", value=self._cap_field(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"""
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 as exc:
# 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
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)