From 1c5d42e4f0d49aea96ab91f5665c84cd8f7b1626 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 2 Jul 2026 23:23:55 -0400 Subject: [PATCH] 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 --- README.md | 18 ++++--- pyproject.toml | 2 +- src/dpy_logger/dpy_logger.py | 95 +++++++++++++++--------------------- 3 files changed, 51 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index e2d74fd..b1868ab 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,18 @@ 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.5 +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.5" +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.4` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.6` suffix from the line above to install the latest unpinned. ## Usage @@ -73,8 +73,9 @@ await bot.log.debug("noisy", log_to_file=False) # -> Discord only ## Errors Resolution failures (unresolvable guild/channel, bad config, a non-text channel — whether -passed as an id or as an already-resolved object) raise `ValueError` from `initialize()` — -a misconfigured logger should fail loudly at setup. Underlying `discord` exceptions +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 @@ -87,8 +88,11 @@ doesn't resolve) never breaks the caller's command. 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 diff --git a/pyproject.toml b/pyproject.toml index 143bba2..0c0db46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dpy_logger" -version = "0.1.5" +version = "0.1.6" description = "Leveled Discord channel logger for discord.py — config-free, injectable, installable." requires-python = ">=3.10" dependencies = [ diff --git a/src/dpy_logger/dpy_logger.py b/src/dpy_logger/dpy_logger.py index 6bd65d3..592fd47 100644 --- a/src/dpy_logger/dpy_logger.py +++ b/src/dpy_logger/dpy_logger.py @@ -1,12 +1,10 @@ """ 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: all static identity is injected at construction; dynamic -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. +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 @@ -25,35 +23,32 @@ 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 -channel this logger was constructed with. +construction channel. -custom embeds: pass embed_builder=fn to restyle without subclassing, where -fn(logger, level, action, actor, details) -> discord.Embed. every level -(including critical) routes through it. for complex cases override the -build_embed method in a subclass instead. +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__)), -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. +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: this base has no feed/announcement method by design. a project -that wants one subclasses DPYLogger and adds it, reusing _resolve. +extending: no feed/announcement method by design; subclass and reuse +_resolve for project-specific log types. -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 -setup. underlying discord exceptions (NotFound/Forbidden/HTTPException/ -InvalidData and other ClientException subclasses) are normalized to that -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. +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 +from datetime import datetime, tzinfo from typing import Callable, Optional _log = logging.getLogger(__name__) @@ -67,6 +62,8 @@ DEFAULT_COLORS = { "critical": 0x000000, } +DISCORD_CONTENT_LIMIT = 2000 + LEVEL_MAP = { "debug": logging.DEBUG, "info": logging.INFO, @@ -109,24 +106,22 @@ class DPYLogger: 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 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. + # 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 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. + # 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") @@ -137,9 +132,7 @@ class DPYLogger: 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 + # fetch_guild raises rather than returning None; normalize to ValueError try: return await self.bot.fetch_guild(guild) except discord.HTTPException as error: @@ -158,16 +151,12 @@ class DPYLogger: return self.channel if isinstance(self.channel, int): # 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 + # 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): - # 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 @@ -178,9 +167,6 @@ class DPYLogger: 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") @@ -197,15 +183,12 @@ class DPYLogger: if self._embed_builder is not None: return self._embed_builder(self, level, action, actor, details) em = discord.Embed(color=self.colors[level]) - # 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 + # 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 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 + # 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] + "..." @@ -220,7 +203,6 @@ class DPYLogger: def _emit_stdlib(self, level, action, actor, log_msg): """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) 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)) @@ -229,10 +211,9 @@ class DPYLogger: 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 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. + 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) @@ -284,4 +265,6 @@ class DPYLogger: 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)