the multi-entry content-join f-strung a dict entry's content with no None guard, so a
{'content': None} entry following a text entry produced a literal 'None' line
('hello' + None -> 'hello\nNone'). a None content now contributes nothing to the join.
Signed-off-by: disqualifier <dev@disqualifier.me>
dpy_paginator
Button-navigated paginator for discord.py. A discord.ui.View that paginates mixed
content (strings, embeds, files, attachments, or dicts of mixed content with custom
buttons) behind previous / jump / next navigation, with an optional cache button.
Install
requirements.txt:
dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.9
Direct:
pip install "dpy_paginator @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_paginator.git@v0.1.9"
Requires discord.py (pulled transitively).
Drop the @v0.1.9 suffix from the line above to install the latest unpinned.
Basic usage
The paginator class is DPYPaginator. It is also exported as ButtonPaginator (a
back-compat alias) — both names refer to the same class, so either import works.
Plain pages — just navigation:
from dpy_paginator import DPYPaginator # or: from dpy_paginator import ButtonPaginator
pages = [discord.Embed(title=f"Page {i}") for i in range(5)]
await DPYPaginator(pages, author_id=ctx.author.id).start(ctx)
start() accepts an Interaction or any Messageable (a Context, channel, etc.).
Emojis
Navigation uses plain Unicode by default — no setup, no emoji upload required. Pass
emojis= to override with custom application/guild emojis the bot can use:
DPYPaginator(pages, emojis={
"previous": "<:icon_back:123...>",
"next": "<:icon_next:123...>",
"cache": "<:icon_cache:123...>",
})
Unset keys fall back to the Unicode defaults.
Page types
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. 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.
With per_page > 1, multiple entries render onto one page. content from each
entry (whether a plain str entry or a dict's content key) joins with \n in
order, same as embeds/files/buttons accumulate — no entry's content is
dropped in favor of another's.
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
Each page can declare its own buttons. They render alongside the navigation row and are rebuilt from the page dict on every render — so mutating a page's button config and re-rendering updates the button live.
async def callback_reorder(interaction, button, data, paginator):
await interaction.response.send_message(f"Reordering {data['email']}", ephemeral=True)
pages = []
for session in sessions:
pages.append({
"content": session["_id"],
"embed": build_embed(session),
"buttons": [
{
"label": "Reorder",
"style": discord.ButtonStyle.green,
"emoji": some_emoji, # optional, your own emoji
"disabled": False,
"data": {"email": session["_id"]},
"callback": callback_reorder,
}
],
})
paginator = DPYPaginator(
pages, cache=None, timeout=900, delete_message_after=True,
mentions_allowed=discord.AllowedMentions.none(), ephemeral=True,
page_text="Session {} of {}",
)
await paginator.start(interaction)
Button config keys: label, style, emoji, disabled, data (arbitrary, passed
to the callback), callback. The callback signature is
async def cb(interaction, button, data, paginator). With no callback, the button
echoes its data.
Cache button (mention priming)
Discord clients render <@id> as a raw id until the user object is cached locally.
The cache button fixes that: pass cache=[...] with one entry per page, where each
entry is a string of the user mentions on that page ("<@111> <@222> <@333>").
When clicked, the button posts the page's mentions in a throwaway ephemeral message
(rendering the <@id> tags primes the viewer's client — allowed_mentions is
none() so no actual ping fires), waits cache_sleep seconds (default 1.0) for the
client to resolve them, then re-renders the current page — so the mentions now
display as names without the user having to flip pages manually.
pages, cache = [], []
for group in groups:
pages.append(build_embed(group))
cache.append(" ".join(f"<@{uid}>" for uid in group["user_ids"]))
await DPYPaginator(pages, cache=cache, cache_sleep=1.0).start(ctx)
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 contentcache— optional per-page cache strings (enables the cache button)author_id— restrict interaction to this usertimeout— seconds before the view stops (default 180)delete_message_after— delete the message on timeoutper_page— entries per page (default 1)mentions_allowed—AllowedMentionsfor sends (default.all())ephemeral— send/edit ephemerallypage_text— format string for the jump button labelemojis— override navigation emojiscache_sleep— seconds to wait after cache-priming before refreshing (default 1.0)
Subclassing
Override format_page(page) to transform pages before rendering (e.g. wrap raw data
in an embed). It may be sync or async.
Versioning
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.