8 Commits
Author SHA1 Message Date
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
4 changed files with 99 additions and 43 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude # claude
CLAUDE.md .claude/
# python # python
__pycache__/ __pycache__/
+14 -10
View File
@@ -10,17 +10,19 @@ 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.1 dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.5
``` ```
Direct: Direct:
```bash ```bash
pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.1" pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.5"
``` ```
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.
## Usage ## Usage
```python ```python
@@ -70,13 +72,15 @@ await bot.log.debug("noisy", log_to_file=False) # -> Discord only
## Errors ## Errors
Resolution failures (unresolvable guild/channel, bad config) raise from `initialize()` Resolution failures (unresolvable guild/channel, bad config, a non-text channel — whether
— a misconfigured logger should fail loudly at setup. The raised type is usually passed as an id or as an already-resolved object) raise `ValueError` from `initialize()`
`ValueError`, but an underlying `discord` exception (`NotFound` / `Forbidden` / a misconfigured logger should fail loudly at setup. Underlying `discord` exceptions
`HTTPException`) from `fetch_guild`/`fetch_channel` can also propagate. On a **per-call** (`NotFound` / `Forbidden` / `HTTPException` / `InvalidData` and other `ClientException`
send, neither resolution nor send failures propagate: they fall back to the stdlib subclasses) from `fetch_guild`/`fetch_channel` are normalized to that `ValueError` on every
logger so a transient Discord failure (or a per-call `guild=` that doesn't resolve) resolution path (construction channel, per-guild settings lookup) so callers see one error
never breaks the caller's command. 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
@@ -139,4 +143,4 @@ class FeedLogger(DPYLogger):
## Versioning ## 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.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dpy_logger" name = "dpy_logger"
version = "0.1.1" version = "0.1.5"
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 = [
+83 -31
View File
@@ -40,11 +40,15 @@ 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 extending: this base has no feed/announcement method by design. a project
that wants one subclasses DPYLogger and adds it, reusing _resolve. that wants one subclasses DPYLogger and adds it, reusing _resolve.
errors: resolution failures (unresolvable guild/channel, bad config) raise 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 ValueError from `initialize()` — a misconfigured logger should fail loudly at
setup. on a per-call send, resolution AND send failures do NOT propagate: they setup. underlying discord exceptions (NotFound/Forbidden/HTTPException/
fall back to the stdlib logger so a transient discord failure (or a per-call InvalidData and other ClientException subclasses) are normalized to that
`guild=` that doesn't resolve) never breaks the caller's command. 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
@@ -111,15 +115,35 @@ class DPYLogger:
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
# setup silently but then fail every send at _get_guild(None) — swallowed, so
# 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):
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 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"""
if guild: if guild:
if isinstance(guild, int): if isinstance(guild, int):
resolved = self.bot.get_guild(guild) or await self.bot.fetch_guild(guild) resolved = self.bot.get_guild(guild)
if not resolved: if resolved is not None:
raise ValueError(f"[dpy_logger] failed to fetch guild {guild}") return resolved
return resolved # fetch_guild never returns None — it raises NotFound/Forbidden/
# HTTPException; normalize those to the lib's ValueError so callers see
# one error type at setup
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): if isinstance(guild, discord.Guild):
return guild return guild
raise ValueError("[dpy_logger] no guild available for logging") raise ValueError("[dpy_logger] no guild available for logging")
@@ -133,16 +157,34 @@ 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):
return await guild.fetch_channel(self.channel) # fetch_channel raises NotFound/Forbidden/HTTPException/InvalidData (a
# ClientException, not an HTTPException); normalize all of those to the
# lib's ValueError so a bad construction channel id fails loud with one
# error type, matching the settings path below
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):
# 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")
return channel
try: try:
channel_id = self.bot.settings[guild.id]["channels"]["logs"] channel_id = self.bot.settings[guild.id]["channels"]["logs"]
channel = await guild.fetch_channel(channel_id)
if not isinstance(channel, discord.TextChannel):
raise ValueError(f"[dpy_logger] configured channel {channel_id} is not a text channel")
return channel
except KeyError: except KeyError:
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}")
try:
channel = await guild.fetch_channel(channel_id)
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
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): def build_embed(self, level, action, actor, details):
"""build the embed for a log call """build the embed for a log call
@@ -155,11 +197,19 @@ 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])
if action: # test `is not None` so a falsy-but-valid value (0, False) still renders; only a
# genuinely absent field (None) or an empty string is dropped/substituted below
if action is not None and str(action) != "":
em.add_field(name="Action", value=f"`{action}`", inline=True) em.add_field(name="Action", value=f"`{action}`", inline=True)
if actor: if actor is not None and str(actor) != "":
em.add_field(name="Actor", value=f"`{actor}`", inline=True) em.add_field(name="Actor", value=f"`{actor}`", inline=True)
em.add_field(name="Log", value=details, inline=False) # 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)"
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.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
@@ -170,20 +220,28 @@ 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)"""
parts = [p for p in (action, str(actor) if actor else None, log_msg) if p] # 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)
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, log_to_file=None): 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 """resolve channel and dispatch a leveled embed; mirror to stdlib unless opted out
stdlib emit happens first so the record survives even if the discord send fails. stdlib emit happens first so the record survives even if the discord send fails.
a failed send falls back to stdlib rather than propagating into the caller. 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:
channel = await self._resolve(guild) channel = await self._resolve(guild)
return await channel.send(embed=self.build_embed(level, action, actor, log_msg)) return await channel.send(
content=content,
embed=self.build_embed(level, action, actor, log_msg),
)
except Exception: except Exception:
_log.exception(f"[dpy_logger] failed to send {level} log: {log_msg}") _log.exception(f"[dpy_logger] failed to send {level} log: {log_msg}")
return None return None
@@ -215,21 +273,15 @@ class DPYLogger:
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)
async def critical(self, log, action=None, actor=None, guild=None, log_to_file=None): async def critical(self, log, action=None, actor=None, guild=None, log_to_file=None):
"""log a critical-level message and ping configured devs""" """log a critical-level message and ping configured devs
if self.log_to_file if log_to_file is None else log_to_file:
self._emit_stdlib("critical", action, actor, log) 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: if self.pings:
content = "alert: " + " ".join(f"<@{u}>" for u in self.pings) content = "alert: " + " ".join(f"<@{u}>" for u in self.pings)
elif self.alert_here: elif self.alert_here:
content = "alert: @here" content = "alert: @here"
else: else:
content = None content = None
try: return await self._send("critical", log, action, actor, guild, log_to_file, content)
channel = await self._resolve(guild)
return await channel.send(
content=content,
embed=self.build_embed("critical", action, actor, log),
)
except Exception:
_log.exception(f"[dpy_logger] failed to send critical log: {log}")
return None