add package: pyproject + src (DPYCache, cache/cache_one/cache_url/resolve/lookup, fail-loud)

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-02 19:20:08 -04:00
parent e06bf12fae
commit 2e93a0bcee
3 changed files with 304 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "dpy_cache"
version = "0.1.0"
description = "Persist images/files to a Discord storage channel and get durable, re-resolvable CDN references — config-free, injectable, installable."
requires-python = ">=3.10"
dependencies = [
"discord.py>=2.0",
"aiohttp>=3.8",
]
[tool.hatch.build.targets.wheel]
packages = ["src/dpy_cache"]
+24
View File
@@ -0,0 +1,24 @@
"""dpy_cache — persist images/files to discord and get durable, re-resolvable references
see :mod:`dpy_cache.dpy_cache` for the full module docstring, api, and error contract
"""
from .dpy_cache import (
MAX_ATTACHMENTS_PER_MESSAGE,
CacheResult,
DPYCache,
DPYCacheError,
FileContent,
FileRef,
)
__version__ = "0.1.0"
__all__ = [
"DPYCache",
"DPYCacheError",
"FileRef",
"CacheResult",
"FileContent",
"MAX_ATTACHMENTS_PER_MESSAGE",
"__version__",
]
+264
View File
@@ -0,0 +1,264 @@
"""persist images/files to discord by uploading to a storage channel, returning durable
references (message jump url + per-file cdn urls keyed by original filename)
mental model
------------
a discord attachment url is effectively permanent storage: upload a file to a channel once
and the attachment lives on discord's cdn forever. this lib wraps "upload to a storage
channel, hand me back the urls" so a bot can stash content and reference it later by its
original filename.
caveat that shapes the api: since late 2023 discord attachment cdn urls are SIGNED and the
signature EXPIRES (~24h). the attachment itself is permanent; only the url's ``ex``/``is``/
``hm`` params go stale, so a raw stored url 403s after expiry. this lib also returns a
re-resolvable ``FileRef`` and a one-call :meth:`DPYCache.resolve` that re-fetches the
message and hands back a freshly-signed url.
usage (the headline is dead-simple single-file caching)::
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/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(s) to the stored message(s)
fresh = await cache.resolve(result.files["avatar.png"]) # later, if the url may be stale
injection / config contract
---------------------------
inject the resolved discord channel object; the lib holds no client and reads no global
config. it calls ``.send`` / ``.fetch_message`` on the channel directly, so any
``discord.abc.Messageable`` that supports both (TextChannel, Thread) works. the bot must
already have access to the channel; an inaccessible or bad channel raises discord's own
error on first use.
error contract (fail-loud)
--------------------------
nothing is swallowed. lib-specific invariants raise :class:`DPYCacheError` (attachment-count
mismatch after a send, ``cache_url`` non-200, a ``lookup`` jump-url pointing at a different
channel than the injected one). unnamed ``bytes`` in a list input raises ``ValueError``. raw
discord errors (``Forbidden`` / ``HTTPException`` / ``NotFound``) propagate UNWRAPPED so
callers can still branch on discord's types.
"""
from __future__ import annotations
import io
import logging
import os
from dataclasses import dataclass
from typing import Union
import aiohttp
import discord
log = logging.getLogger(__name__)
MAX_ATTACHMENTS_PER_MESSAGE = 10
FileContent = Union[bytes, str, "os.PathLike[str]", discord.File]
class DPYCacheError(Exception):
"""raised for dpy_cache's own invariants (count mismatch, remote fetch failure,
channel mismatch); raw discord errors propagate unwrapped instead"""
@dataclass
class FileRef:
"""a durable reference to one cached attachment; ``url`` may expire (~24h), re-fetch a
fresh one via :meth:`DPYCache.resolve`"""
filename: str
url: str
message_id: int
channel_id: int
attachment_id: int
@dataclass
class CacheResult:
"""result of a :meth:`DPYCache.cache` call; ``message_url`` is a single jump url for one
batch or a list of jump urls when the input spanned more than one message"""
message_url: Union[str, list[str]]
files: dict[str, FileRef]
def _basename(path: "str | os.PathLike[str]") -> str:
"""filename portion of a path-like, as the map key for list inputs"""
return os.path.basename(os.fspath(path))
def _ref_from_attachment(name: str, attachment: discord.Attachment, message: discord.Message) -> FileRef:
"""build a FileRef from a sent attachment and its message"""
return FileRef(
filename=name,
url=attachment.url,
message_id=message.id,
channel_id=message.channel.id,
attachment_id=attachment.id,
)
class DPYCache:
"""upload files to an injected storage channel and hand back durable references"""
def __init__(self, channel: discord.abc.Messageable) -> None:
"""store the injected storage channel; caller resolves it, lib holds no client"""
self._channel = channel
async def cache(
self,
files: "dict[str, FileContent] | list[FileContent]",
*,
content: "str | None" = None,
) -> CacheResult:
"""upload files to the storage channel (batching over 10/message) and return refs
keyed by original filename; raises on unnamed bytes, count mismatch, or a discord
send error"""
items = self._normalize(files)
merged: dict[str, FileRef] = {}
jump_urls: list[str] = []
for start in range(0, len(items), MAX_ATTACHMENTS_PER_MESSAGE):
group = items[start:start + MAX_ATTACHMENTS_PER_MESSAGE]
discord_files = [self._to_file(name, source) for name, source in group]
try:
message = await self._channel.send(content=content, files=discord_files)
except (discord.Forbidden, discord.HTTPException):
log.error("dpy_cache: send of %d file(s) to the storage channel failed", len(group))
raise
attachments = list(message.attachments)
if len(attachments) != len(group):
sent_names = [name for name, _ in group]
got = len(attachments)
missing = sent_names[got:] if got < len(sent_names) else sent_names
raise DPYCacheError(
f"discord returned {got} attachment(s) for {len(group)} sent file(s); "
f"dropped: {', '.join(missing)}"
)
for (name, _), attachment in zip(group, attachments):
merged[name] = _ref_from_attachment(name, attachment, message)
jump_urls.append(message.jump_url)
message_url: "str | list[str]" = jump_urls[0] if len(jump_urls) == 1 else jump_urls
return CacheResult(message_url=message_url, files=merged)
async def cache_one(
self,
name: str,
data: FileContent,
*,
content: "str | None" = None,
) -> FileRef:
"""cache a single file, return its ref"""
result = await self.cache({name: data}, content=content)
return result.files[name]
async def cache_url(
self,
name: str,
url: str,
*,
content: "str | None" = None,
) -> FileRef:
"""fetch a remote url then cache its bytes as ``name``; raises DPYCacheError on
non-200"""
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status != 200:
raise DPYCacheError(f"cache_url: GET {url} returned status {response.status}")
data = await response.read()
return await self.cache_one(name, data, content=content)
async def resolve(self, ref: FileRef) -> str:
"""re-fetch the message and return a fresh (unexpired) cdn url for the attachment;
raises if the message or attachment is gone (never a stale url)
a gone MESSAGE propagates discord's own ``NotFound`` from ``fetch_message``; a gone
ATTACHMENT on a still-live message raises :class:`DPYCacheError` (the lib's own domain
fault) rather than a fabricated discord internal error"""
message = await self._channel.fetch_message(ref.message_id)
for attachment in message.attachments:
if attachment.id == ref.attachment_id:
return attachment.url
raise DPYCacheError(
f"attachment {ref.attachment_id} ({ref.filename}) no longer on message {ref.message_id}"
)
async def lookup(self, message: "str | int | discord.Message") -> dict[str, FileRef]:
"""reverse lookup: a jump-url, message id, or Message -> {filename: FileRef}; raises
(NotFound) if the message is gone, never an empty map for a missing message"""
resolved = await self._resolve_message(message)
return {
attachment.filename: _ref_from_attachment(attachment.filename, attachment, resolved)
for attachment in resolved.attachments
}
def _normalize(self, files: "dict[str, FileContent] | list[FileContent]") -> "list[tuple[str, FileContent]]":
"""normalize dict/list input to an ordered list of (name, source); raises ValueError
on unnamed bytes in a list"""
if isinstance(files, dict):
return list(files.items())
items: "list[tuple[str, FileContent]]" = []
for source in files:
if isinstance(source, discord.File):
items.append((source.filename, source))
elif isinstance(source, (str, os.PathLike)):
items.append((_basename(source), source))
elif isinstance(source, (bytes, bytearray)):
raise ValueError(
"raw bytes in a list input have no filename; pass a dict {name: bytes} "
"or a discord.File for named bytes"
)
else:
raise ValueError(f"unsupported file content type in list: {type(source).__name__}")
return items
def _to_file(self, name: str, source: FileContent) -> discord.File:
"""build a fresh discord.File for ``source`` named ``name``; a fresh File is built
per send so the source survives multi-batch and retries"""
if isinstance(source, discord.File):
source.filename = name
return source
if isinstance(source, (bytes, bytearray)):
return discord.File(io.BytesIO(bytes(source)), filename=name)
if isinstance(source, (str, os.PathLike)):
return discord.File(source, filename=name)
raise ValueError(f"unsupported file content type: {type(source).__name__}")
async def _resolve_message(self, message: "str | int | discord.Message") -> discord.Message:
"""turn a jump-url / id / Message into a fetched Message on the injected channel"""
if isinstance(message, discord.Message):
return message
if isinstance(message, int):
return await self._channel.fetch_message(message)
if isinstance(message, str):
channel_id, message_id = self._parse_jump_url(message)
if channel_id is not None and channel_id != self._channel.id:
raise DPYCacheError(
f"lookup jump-url targets channel {channel_id} but the injected channel is "
f"{self._channel.id}; this lib holds no client and can only fetch its own channel"
)
return await self._channel.fetch_message(message_id)
raise ValueError(f"unsupported lookup input type: {type(message).__name__}")
@staticmethod
def _parse_jump_url(url: str) -> "tuple[int | None, int]":
"""parse (channel_id, message_id) out of a discord jump url; raises DPYCacheError on
a malformed url"""
parts = url.rstrip("/").split("/")
try:
message_id = int(parts[-1])
channel_id = int(parts[-2])
except (IndexError, ValueError):
raise DPYCacheError(f"lookup: not a valid discord jump url: {url}")
return channel_id, message_id