From 8addecd64f142b1800d790088187d74980b2e6aa Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 2 Jul 2026 23:23:26 -0400 Subject: [PATCH] fix: cache button renders on single page; cache_button edit swallows deleted/expired message (v0.1.7) update_buttons gated the cache button behind the nav row, so a single-page paginator with a non-empty cache never showed it despite the README promising otherwise; the cache button now renders whenever self.cache is truthy, independent of nav, and start() keeps the view alive for that case too. cache_button's post-sleep message.edit was unguarded, so a message deleted or expired during cache_sleep raised out of the callback; it now mirrors on_timeout's NotFound/Forbidden swallow and HTTPException log. also compresses the module and method docstrings (no behavior change). Signed-off-by: disqualifier --- README.md | 10 ++- pyproject.toml | 2 +- src/dpy_paginator/dpy_paginator.py | 129 ++++++++++++----------------- 3 files changed, 62 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index fcfe6b1..c3fc72b 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,18 @@ buttons) behind previous / jump / next navigation, with an optional cache button `requirements.txt`: ``` -dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.6 +dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.7 ``` Direct: ```bash -pip install "dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.6" +pip install "dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.7" ``` Requires `discord.py` (pulled transitively). -Drop the `@v0.1.6` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.7` suffix from the line above to install the latest unpinned. ## Basic usage @@ -134,6 +134,10 @@ Omit `cache` or pass `None`/`[]` and the button never appears. When set, `cache` must have one entry per rendered page — a shorter `cache` raises `ValueError` at construction rather than failing with an `IndexError` mid-navigation. +The cache button renders whenever `cache` is truthy, even on a single page (no +navigation row) — it doesn't depend on there being more than one page to flip +through. + ## Constructor options - `pages` — sequence of page content diff --git a/pyproject.toml b/pyproject.toml index 83e13a5..acc180c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dpy_paginator" -version = "0.1.6" +version = "0.1.7" description = "Button-navigated paginator for discord.py — config-free, injectable emojis, installable." requires-python = ">=3.10" dependencies = [ diff --git a/src/dpy_paginator/dpy_paginator.py b/src/dpy_paginator/dpy_paginator.py index d80e9e3..698d533 100644 --- a/src/dpy_paginator/dpy_paginator.py +++ b/src/dpy_paginator/dpy_paginator.py @@ -10,32 +10,20 @@ previous / jump / next navigation, with an optional cache button. pages = [discord.Embed(title=f"Page {i}") for i in range(5)] await DPYPaginator(pages, author_id=ctx.author.id).start(ctx) -emojis: navigation uses plain unicode by default (no setup). pass emojis= to -override with custom application/guild emojis the bot can use: +emojis: unicode by default; override via emojis={"previous"/"next"/"cache": ...}. - DPYPaginator(pages, emojis={ - "previous": "<:icon_back:123...>", - "next": "<:icon_next:123...>", - "cache": "<:icon_cache:123...>", - }) +page types: str, discord.Embed, discord.File/Attachment, a sequence of those, or a +dict carrying 'content'/'embed(s)'/'file(s)' plus a 'buttons' list of custom button +configs (see README). both 'file' and 'files' accept a discord.Attachment and +convert it via to_file() automatically. -page types: a page may be a str, a discord.Embed, a discord.File/Attachment, -a sequence of those, or a dict. a dict page can carry 'content'/'embed(s)'/ -'file(s)' plus a 'buttons' list of custom button configs (see README). both -the singular 'file' and plural 'files' dict keys accept a discord.Attachment -and convert it via to_file() — neither key requires the caller to convert -first. +file pages: discord.py closes a discord.File's handle after every send/edit, so a +fresh discord.File is rebuilt from the same source on every render (including the +first) instead of resending the consumer's object. -file pages are re-rendered safely: discord.py closes a discord.File's -underlying handle after every send/edit, so the paginator never resends a -consumer-supplied File object directly — it rebuilds a fresh discord.File -from the same source (path or buffer) on every render, including the first. - -concurrency: get_page_kwargs builds each page's kwargs/buttons in local -state and only publishes them once the whole page (including any awaited -format_page or Attachment.to_file() conversions) is fully assembled, so two -near-simultaneous interactions (e.g. rapid button clicks) never interleave -into a merged or shared render. +concurrency: get_page_kwargs builds each page's kwargs/buttons in local state and +only publishes them once the whole page is fully assembled, so near-simultaneous +interactions (e.g. rapid button clicks) never interleave into a shared render. config-free: no host config import; everything is passed at construction. """ @@ -183,8 +171,6 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): if not pages: raise ValueError("DPYPaginator requires at least one page") if per_page < 1: - # per_page <= 0 would ZeroDivisionError (==0) or yield a negative max_pages - # (<0) at the divmod below; fail loud like the other construction guards raise ValueError("per_page must be >= 1") self.author_id: Optional[int] = author_id self.delete_message_after: bool = delete_message_after @@ -201,8 +187,7 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): total_pages, left_over = divmod(len(self.pages), self.per_page) self.max_pages: int = total_pages + (1 if left_over else 0) - # a falsy cache (None or []) disables the cache button (see the render path's - # `if self.cache:`); only a non-empty cache must have one entry per page + # falsy cache (None/[]) disables the cache button; non-empty needs one entry per page if cache and len(cache) < self.max_pages: raise ValueError( f"cache has {len(cache)} entries but there are {self.max_pages} pages; " @@ -252,10 +237,9 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): ) -> Dict[str, Any]: """build the send/edit kwargs for a page, extracting any custom buttons - builds into local state and publishes self.current_page_buttons only once - the whole page (including any nested recursion and awaited conversions) is - fully assembled, so an interleaved concurrent call never observes or - mutates a partially-built page (see _get_page_kwargs). + builds into local state (see _get_page_kwargs) and publishes + self.current_page_buttons only once the page is fully assembled, so an + interleaved concurrent call never observes a partially-built page. """ kwargs, buttons = await self._get_page_kwargs(page, skip_formatting=skip_formatting) self.current_page_buttons = buttons @@ -268,11 +252,10 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): kwargs: Optional[Dict[str, Any]] = None, buttons: Optional[List[discord.ui.Button]] = None, ) -> Tuple[Dict[str, Any], List[discord.ui.Button]]: - """recursive worker: build kwargs/buttons in locals, threaded through recursion + """recursive worker: builds kwargs/buttons in locals threaded through recursion - instance attributes are never read or written mid-build, so concurrent - interactions each hold their own kwargs/buttons until get_page_kwargs - publishes the finished result. + instance attributes are never touched mid-build, so concurrent interactions + each hold their own state until get_page_kwargs publishes the result. """ if kwargs is None: kwargs = self._fresh_kwargs() @@ -314,10 +297,8 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): value = await value.to_file() kwargs["files"].append(value) elif key in ("embed", "embeds", "file", "files"): - # a wrong-typed embed/file would otherwise fall to the catch-all and - # be forwarded verbatim, colliding with the base embeds=[]/files=[] - # and raising an opaque TypeError from inside discord.py — reject it - # here with a clear, paginator-side message + # wrong-typed value would otherwise collide with base embeds=[]/files=[] + # and raise an opaque TypeError from inside discord.py — reject it here raise ValueError( f"page key {key!r} has unexpected type {type(value).__name__}" ) @@ -345,11 +326,10 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): def update_buttons(self, nav: bool = True) -> None: """rebuild the action row for the current page state - nav=False rebuilds with only the page's custom buttons and no navigation - items (prev/jump/cache/next) — a single-page result that still carries - custom buttons keeps them (and their live callbacks) without a nav row. - the nav row is also suppressed for a single page (max_pages < 2) even when - nav is left at its default, so a re-render (update_page) never resurrects it. + nav=False (or a single page, max_pages < 2) drops prev/jump/next but keeps + the page's custom buttons and the cache button — cache renders whenever + self.cache is truthy, independent of the nav row, so a single-page result + with a cache still gets its cache button. """ self.clear_items() @@ -366,11 +346,11 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): for button in self.current_page_buttons: self.add_item(button) - if nav: - if self.cache: - self.cache_button.emoji = self.emojis["cache"] - self.add_item(self.cache_button) + if self.cache: + self.cache_button.emoji = self.emojis["cache"] + self.add_item(self.cache_button) + if nav: self.next_page.emoji = self.emojis["next"] self.next_page.disabled = self.current_page >= self.max_pages - 1 self.add_item(self.next_page) @@ -405,13 +385,14 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): async def cache_button(self, interaction: discord.Interaction, _: discord.ui.Button) -> None: """prime the viewer's client mention cache, then refresh the current view - discord clients render <@id> as a raw id until the user object is cached. - this posts the page's mentions in a throwaway ephemeral message so the client - resolves them, waits cache_sleep seconds, then re-edits the message so the - mentions display as names — no manual page-flip needed. + this is a mention-cache primer, not a data cache: discord clients render + <@id> as a raw id until the user is cached, so this posts the page's + mentions in a throwaway ephemeral message (allowed_mentions=none() — tags + render, no ping fires) to force resolution, waits cache_sleep seconds, then + re-edits the message so mentions display as names. - allowed_mentions is none() on purpose: the <@id> tags still render (which is - what primes the cache) but no actual ping/notification fires. + expected discord failures (deleted message, no permission) are swallowed + like on_timeout — an expired/deleted message must not raise here. """ await interaction.response.send_message( content=self.cache[self.current_page], @@ -422,7 +403,12 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): await asyncio.sleep(self.cache_sleep) kwargs = await self._build_render_kwargs() if self.message: - await self.message.edit(**kwargs) + try: + await self.message.edit(**kwargs) + except (discord.NotFound, discord.Forbidden): + pass + except discord.HTTPException: + log.warning("paginator cache_button: failed to refresh message", exc_info=True) @discord.ui.button(style=discord.ButtonStyle.blurple) async def next_page(self, interaction: Interaction, _: discord.ui.Button[Self]) -> None: @@ -433,12 +419,9 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): def reset_files(self, page_kwargs: Dict[str, Any]) -> None: """swap in a fresh discord.File per render so a re-render never reuses a sent one - discord.py closes a File's underlying fp after every send/edit - (MultipartParameters.__exit__ -> file.close()), so the SAME File object is - single-use. Rather than call file.reset() (which raises 'seek of closed - file' the moment a path-backed File has actually been closed), rebuild a - new discord.File from the same source for every entry — cheap, and safe - whether or not the previous render already consumed it. + discord.py closes a File's fp after every send/edit, making it single-use; + file.reset() raises once a path-backed File is actually closed, so rebuild a + new discord.File from the same source instead — cheap, and safe either way. """ files = page_kwargs.get("files") if not files: @@ -449,13 +432,10 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): def _fresh_file(file: discord.File) -> discord.File: """build a new discord.File from an existing one's source - a path-backed File (fp opened internally, _owner=True) is reopened from - fp.name, the path it was constructed with — fp.name stays readable even - after discord.py has closed the handle, so this works whether or not the - previous render already consumed it. a buffer-backed File (caller passed - an io object, _owner=False) is never closed by discord.py (see - File.close(), which only closes _owner=True files) so its buffer is - rewound to its starting position and reused directly. + a path-backed File (_owner=True) is reopened from fp.name, which stays + readable after discord.py closes the handle. a buffer-backed File + (_owner=False) is never closed by discord.py, so its buffer is rewound and + reused directly. """ if file._owner: source = file.fp.name @@ -474,13 +454,12 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): ) -> Optional[Union[discord.Message, discord.WebhookMessage]]: """send the first page; obj is an Interaction or a Messageable""" kwargs = await self.get_page_kwargs(self.get_page(self.current_page)) - # update_buttons already suppresses the nav row for a single page (max_pages < 2), - # keeping only the page's custom buttons — so a single page with custom buttons - # renders them (live callbacks) with no nav row + # single page (max_pages < 2): update_buttons already drops the nav row but + # keeps custom/cache buttons — so a single page with either still renders + # them (live callbacks); only drop the view entirely if neither is present self.update_buttons() - if self.max_pages < 2 and not self.current_page_buttons: - # single page, no custom buttons: no interactive row at all, drop the view + if self.max_pages < 2 and not self.current_page_buttons and not self.cache: self.stop() kwargs.pop("view", None) @@ -514,8 +493,8 @@ class DPYPaginator(Generic[PageT_co], discord.ui.View): except (discord.NotFound, discord.Forbidden): pass except discord.HTTPException: - # on_timeout runs as a fire-and-forget task; a transient delete failure must - # not surface as an unretrieved-task traceback on a best-effort cleanup + # fire-and-forget task; a transient failure must not surface as an + # unretrieved-task traceback on a best-effort cleanup log.warning("paginator on_timeout: failed to delete message", exc_info=True)