dsql 51a4f7a817 fix: pre-validate all cache() inputs before any upload so a bad late input orphans nothing
cache() built discord.File objects per batch inside the send loop, so an unsupported type
or a missing file path in the 11th+ item raised only after earlier batches were already
uploaded - orphaning those stored messages with no returned ref. A new _prevalidate pass
checks every source up front (supported type; a path source exists and is a file) WITHOUT
opening any File or reading bytes, so a locally-detectable bad input fails loud before the
first send. The File is still built per batch in the loop (fresh-File-per-send unchanged),
so no extra open FDs are held. Neg control: old code uploaded batch 1 then raised on the
bad path (sends=1, orphaned); new raises with sends=0.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 20:02:19 -04:00

dpy_cache

Persist images/files to Discord by uploading them to a storage channel, and get back durable references — a message jump URL plus per-file CDN URLs keyed by their original filename. Discord attachments live on the CDN permanently; this lib wraps "stash it, hand me a URL" and also returns a re-resolvable reference so the ~24h URL-signature expiry never bites you.

Install

dpy_cache @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_cache.git@v0.1.2

Usage

The headline is dead-simple single-file caching:

from dpy_cache import DPYCache

cache = DPYCache(bot.get_channel(STORAGE_CHANNEL_ID))

url = (await cache.cache_one("img.png", image_bytes)).url    # raw bytes -> url
url = (await cache.cache_url("img.png", "https://...")).url  # remote url -> url

Several files at once (batches automatically over 10 per message):

result = await cache.cache({
    "avatar.png": avatar_bytes,
    "banner.png": "/tmp/banner.png",
})
result.files["avatar.png"].url      # cdn url now
result.message_url                  # jump link to the stored message

# later, if the url may have expired:
fresh = await cache.resolve(result.files["avatar.png"])

Reverse lookup a stored message back into refs:

refs = await cache.lookup(result.message_url)   # jump url, message id, or discord.Message
refs["avatar.png"].url

lookup() accepts a message id as an int or as a digit-only str (the snowflake shape JSON/DBs store), e.g. cache.lookup("123456789012345678").

What you inject

A resolved Discord channel object (discord.abc.Messageable that supports .send and .fetch_message — a TextChannel or Thread). The lib holds no client and reads no global config; it calls those two methods on the channel directly. The bot must already have access to the channel — an inaccessible or bad channel raises Discord's own error on first use.

FileContent inputs

cache_one / cache accept, per file: bytes, a filesystem path (str or os.PathLike), or a discord.File. In a list input, raw bytes have no filename and raise ValueError — pass a dict {name: bytes} or a discord.File to name bytes. A list input whose sources resolve to the same name (two paths with the same basename, two discord.Files with the same .filename, ...) raises DPYCacheError before any upload — a dict input can't collide since dict keys are already unique.

A discord.File source is safe to reuse across multiple names/batches: cache() always builds a genuinely fresh discord.File per send (re-opening a path-backed source, or seeking + copying a caller-supplied stream) rather than reusing the object you passed in. An opaque stream that can't be safely re-read (already closed, not seekable) raises DPYCacheError instead of silently sending stale or empty data.

Why resolve() exists

Since late 2023, Discord attachment CDN URLs are signed and the signature expires (~24h). The attachment itself is permanent; only the URL goes stale (a raw stored URL 403s after expiry). FileRef carries the message/channel/attachment ids, and resolve(ref) re-fetches the message and returns a freshly-signed URL. Use the URL immediately and you can ignore resolve(); persist a reference and you call resolve() when you read it back.

API & contract

The module docstring (help(dpy_cache) / IDE hover) is the source of truth for the full API. In short:

  • cache(files, *, content=None) -> CacheResult
  • cache_one(name, data, *, content=None) -> FileRef
  • cache_url(name, url, *, content=None) -> FileRef
  • resolve(ref) -> str
  • lookup(message) -> dict[str, FileRef]

Fail-loud. Nothing is swallowed. Lib-specific invariants raise DPYCacheError (attachment-count mismatch after a send, a duplicate filename in a list input to cache(), cache_url non-200, a lookup jump-URL pointing at a different channel than the injected one, a discord.File source that can't be safely rebuilt for a repeat send). Unnamed bytes in a list raise ValueError. Raw Discord errors (Forbidden / HTTPException / NotFound) propagate unwrapped so you can still branch on Discord's own types.

Versioning

Tagged vX.Y.Z; pin a tag in your install line. Targets discord.py>=2.0 (not discord.py-self).

v0.1.2

  • __version__ is now derived from installed package metadata (importlib.metadata.version("dpy_cache")) instead of a hardcoded string, so it can no longer drift from the pyproject.toml version that the release tag is cut from. A not-installed/editable checkout falls back to a literal that is kept in sync with the current pyproject.toml version at each release.

v0.1.1

  • cache() now rejects a duplicate filename in a list input with DPYCacheError before any upload, instead of silently uploading both files and losing the first FileRef at merge time.
  • _to_file now builds a genuinely fresh discord.File per send for discord.File sources (previously returned the same object, which could swap filename/URL when one File was reused under two names, or re-send 0 bytes / raise ValueError on a spent stream).
  • lookup() now accepts a digit-only message-id string in addition to int.
  • Dropped the redundant discord.Forbidden member from an except tuple (it already subclasses discord.HTTPException); no behavior change.
S
Description
Discord-channel asset cacher for discord.py
Readme
92 KiB
Languages
Python 100%