diff --git a/README.md b/README.md index adb0b9b..56861f1 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.4 +dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.5 ``` Direct: ```bash -pip install "dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.4" +pip install "dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.5" ``` Requires `discord.py` (pulled transitively). -Drop the `@v0.1.4` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.5` suffix from the line above to install the latest unpinned. ## Basic usage @@ -54,7 +54,15 @@ Unset keys fall back to the Unicode defaults. A page may be a `str`, `discord.Embed`, `discord.File`/`Attachment`, a sequence of those, or a `dict`. A dict page can carry `content`, `embed`/`embeds`, -`file`/`files`, and a `buttons` list of custom button configs. +`file`/`files`, and a `buttons` list of custom button configs. Both `file` and +`files` accept a `discord.Attachment` and convert it via `to_file()` automatically — +you never need to convert an attachment before passing it in. + +File pages are safe to navigate back to. discord.py closes a `discord.File`'s +underlying handle after every send/edit, so the paginator never resends your File +object directly — it rebuilds a fresh `discord.File` from the same source (path or +buffer) on every render, so `pages` can hold a `discord.File` once and be paged +back and forth indefinitely. ## Custom per-page buttons diff --git a/pyproject.toml b/pyproject.toml index b7a77a6..b0691d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dpy_paginator" -version = "0.1.4" +version = "0.1.5" 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 f2516b3..23e3fa3 100644 --- a/src/dpy_paginator/dpy_paginator.py +++ b/src/dpy_paginator/dpy_paginator.py @@ -21,7 +21,21 @@ override with custom application/guild emojis the bot can use: 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). +'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 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. config-free: no host config import; everything is passed at construction. """ @@ -190,7 +204,6 @@ class ButtonPaginator(Generic[PageT_co], discord.ui.View): f"cache has {len(cache)} entries but there are {self.max_pages} pages; " "cache needs one entry per page" ) - self._page_kwargs: Dict[str, Any] = self._fresh_kwargs() def _fresh_kwargs(self) -> Dict[str, Any]: """a clean page-kwargs dict""" @@ -233,19 +246,44 @@ class ButtonPaginator(Generic[PageT_co], discord.ui.View): async def get_page_kwargs( self, page: Union[PageT_co, Sequence[PageT_co]], skip_formatting: bool = False ) -> Dict[str, Any]: - """build the send/edit kwargs for a page, extracting any custom buttons""" + """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). + """ + kwargs, buttons = await self._get_page_kwargs(page, skip_formatting=skip_formatting) + self.current_page_buttons = buttons + return kwargs + + async def _get_page_kwargs( + self, + page: Union[PageT_co, Sequence[PageT_co]], + skip_formatting: bool = False, + 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 + + 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. + """ + if kwargs is None: + kwargs = self._fresh_kwargs() + if buttons is None: + buttons = [] + if not skip_formatting: - self._page_kwargs = self._fresh_kwargs() formatted_page = await discord.utils.maybe_coroutine(self.format_page, page) else: formatted_page = page - self.current_page_buttons = [] - if isinstance(formatted_page, dict): formatted_page = dict(formatted_page) for config in formatted_page.pop("buttons", []): - self.current_page_buttons.append( + buttons.append( _CustomButton( label=config.get("label", "Button"), style=config.get("style", discord.ButtonStyle.gray), @@ -259,15 +297,18 @@ class ButtonPaginator(Generic[PageT_co], discord.ui.View): for key, value in formatted_page.items(): if key == "embeds" and isinstance(value, list): - self._page_kwargs["embeds"].extend(value) + kwargs["embeds"].extend(value) elif key == "embed" and isinstance(value, discord.Embed): - self._page_kwargs["embeds"].append(value) + kwargs["embeds"].append(value) elif key == "files" and isinstance(value, list): - self._page_kwargs["files"].extend(value) + for item in value: + if isinstance(item, discord.Attachment): + item = await item.to_file() + kwargs["files"].append(item) elif key == "file" and isinstance(value, (discord.File, discord.Attachment)): if isinstance(value, discord.Attachment): value = await value.to_file() - self._page_kwargs["files"].append(value) + 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=[] @@ -277,25 +318,25 @@ class ButtonPaginator(Generic[PageT_co], discord.ui.View): f"page key {key!r} has unexpected type {type(value).__name__}" ) else: - self._page_kwargs[key] = value + kwargs[key] = value elif isinstance(formatted_page, str): - content = self._page_kwargs["content"] - self._page_kwargs["content"] = ( + content = kwargs["content"] + kwargs["content"] = ( formatted_page if content is None else f"{content}\n{formatted_page}" ) elif isinstance(formatted_page, discord.Embed): - self._page_kwargs["embeds"].append(formatted_page) + kwargs["embeds"].append(formatted_page) elif isinstance(formatted_page, (discord.File, discord.Attachment)): if isinstance(formatted_page, discord.Attachment): formatted_page = await formatted_page.to_file() - self._page_kwargs["files"].append(formatted_page) + kwargs["files"].append(formatted_page) elif isinstance(formatted_page, (tuple, list)): for item in formatted_page: - await self.get_page_kwargs(item, skip_formatting=True) + await self._get_page_kwargs(item, skip_formatting=True, kwargs=kwargs, buttons=buttons) else: raise TypeError("page content must be str, discord.Embed, file/attachment, sequence, or dict") - return self._page_kwargs + return kwargs, buttons def update_buttons(self, nav: bool = True) -> None: """rebuild the action row for the current page state @@ -386,9 +427,43 @@ class ButtonPaginator(Generic[PageT_co], discord.ui.View): await self.update_page(interaction) def reset_files(self, page_kwargs: Dict[str, Any]) -> None: - """rewind file pointers so they can be sent again""" - for file in page_kwargs.get("files", []): - file.reset() + """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. + """ + files = page_kwargs.get("files") + if not files: + return + page_kwargs["files"] = [self._fresh_file(file) for file in files] + + @staticmethod + 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. + """ + if file._owner: + source = file.fp.name + else: + source = file.fp + source.seek(file._original_pos) + return discord.File( + source, + filename=file.filename, + spoiler=file.spoiler, + description=file.description, + ) async def start( self, obj: Union[Interaction, Messageable], **send_kwargs: Any