11 Commits
Author SHA1 Message Date
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
dsql a90a0061b0 init: leveled discord logger
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-24 21:24:39 -04:00
4 changed files with 67 additions and 35 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude
CLAUDE.md
.claude/
# python
__pycache__/
+17 -12
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.2
```
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.2"
```
Requires `discord.py` (pulled transitively).
Drop the `@v0.1.2` suffix from the line above to install the latest unpinned.
## Usage
```python
@@ -28,11 +30,11 @@ from dpy_logger import DPYLogger
bot.log = DPYLogger(
bot, guild_id, channel_id,
colors=cfg.log_colors, # optional; merged over sensible defaults
pings=cfg.authorized_devs, # mentioned on critical()
timezone=cfg.timezone,
footer=cfg.bot_footer,
avatar=cfg.bot_avatar,
colors=log_colors, # optional; merged over sensible defaults
pings=authorized_devs, # mentioned on critical()
timezone=tz,
footer=bot_footer,
avatar=bot_avatar,
)
await bot.log.initialize() # resolves ids -> objects, call once
@@ -70,10 +72,13 @@ 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) raise
`ValueError` from `initialize()` — a misconfigured logger should fail loudly at setup.
Underlying `discord` exceptions (`NotFound` / `Forbidden` / `HTTPException`) from
`fetch_guild`/`fetch_channel` are normalized to that `ValueError` 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
@@ -136,4 +141,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.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "dpy_logger"
version = "0.1.0"
version = "0.1.2"
description = "Leveled Discord channel logger for discord.py — config-free, injectable, installable."
requires-python = ">=3.10"
dependencies = [
+48 -21
View File
@@ -12,11 +12,11 @@ command that mutates bot.settings changes routing without a restart.
bot.log = DPYLogger(
bot, guild_id, channel_id,
colors=cfg.log_colors, # optional, falls back to defaults
pings=cfg.authorized_devs, # mentioned on critical()
timezone=cfg.timezone,
footer=cfg.bot_footer,
avatar=cfg.bot_avatar,
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)
@@ -41,9 +41,10 @@ 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 at initialize/call time — a misconfigured logger should fail loudly.
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.
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
@@ -115,10 +116,16 @@ class DPYLogger:
"""resolve a guild from id-or-object, raising if unresolvable"""
if guild:
if isinstance(guild, int):
resolved = self.bot.get_guild(guild) or await self.bot.fetch_guild(guild)
if not resolved:
raise ValueError(f"[dpy_logger] failed to fetch guild {guild}")
return resolved
resolved = self.bot.get_guild(guild)
if resolved is not None:
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):
return guild
raise ValueError("[dpy_logger] no guild available for logging")
@@ -128,20 +135,30 @@ class DPYLogger:
if not guild:
raise ValueError("[dpy_logger] cannot resolve channel without a guild")
if not override and guild == self.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):
return await guild.fetch_channel(self.channel)
channel = await guild.fetch_channel(self.channel)
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:
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:
raise ValueError(f"[dpy_logger] no log channel configured for guild {guild.id}")
try:
channel = await guild.fetch_channel(channel_id)
except discord.HTTPException 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
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
@@ -158,7 +175,13 @@ class DPYLogger:
em.add_field(name="Action", value=f"`{action}`", inline=True)
if actor:
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 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
@@ -205,8 +228,12 @@ class DPYLogger:
failure = fail
async def task(self, log, action=None, guild=None, log_to_file=None):
"""log a task-level message with SYSTEM/TASK as the actor"""
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):