From 7d878845df1d918776d72931a031f06c98a544a4 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 2 Jul 2026 19:59:13 -0400 Subject: [PATCH] add package: pyproject + src (DPYAppEmojis, folder-mirror refresh, dot-access, fail-loud) Signed-off-by: disqualifier --- pyproject.toml | 15 +++ src/dpy_appemojis/__init__.py | 26 ++++ src/dpy_appemojis/dpy_appemojis.py | 190 +++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/dpy_appemojis/__init__.py create mode 100644 src/dpy_appemojis/dpy_appemojis.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cc221cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "dpy_appemojis" +version = "0.1.0" +description = "Mirror a project folder onto the bot's application emojis for discord.py — folder is the source of truth. Config-free, injectable, installable." +requires-python = ">=3.10" +dependencies = [ + "discord.py>=2.5", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/dpy_appemojis"] diff --git a/src/dpy_appemojis/__init__.py b/src/dpy_appemojis/__init__.py new file mode 100644 index 0000000..3e83e29 --- /dev/null +++ b/src/dpy_appemojis/__init__.py @@ -0,0 +1,26 @@ +"""dpy_appemojis — mirror a project folder onto the bot's application emojis + +see :mod:`dpy_appemojis.dpy_appemojis` for the full module docstring, api, and error contract +""" +from .dpy_appemojis import ( + EMOJI_DIR, + MAX_APP_EMOJIS, + MAX_EMOJI_BYTES, + VALID_EXTS, + DPYAppEmojis, + DPYAppEmojisError, + RefreshResult, +) + +__version__ = "0.1.0" + +__all__ = [ + "DPYAppEmojis", + "DPYAppEmojisError", + "RefreshResult", + "EMOJI_DIR", + "VALID_EXTS", + "MAX_EMOJI_BYTES", + "MAX_APP_EMOJIS", + "__version__", +] diff --git a/src/dpy_appemojis/dpy_appemojis.py b/src/dpy_appemojis/dpy_appemojis.py new file mode 100644 index 0000000..ea5c36f --- /dev/null +++ b/src/dpy_appemojis/dpy_appemojis.py @@ -0,0 +1,190 @@ +"""sync a project folder to the bot's APPLICATION emojis (bot-owned, usable in any guild the +app is in — not guild emojis) + +mental model +------------ +application emojis live on the bot's application (usable anywhere the app is, capped at 2000). +this lib makes a folder the source of truth: ``assets/emojis`` holds image files, and +:meth:`DPYAppEmojis.refresh` makes the app's emoji set MIRROR the folder — create emojis for +new files, delete emojis whose file was removed. the emoji name is the filename stem. + +usage:: + + appmojis = DPYAppEmojis(bot) + await appmojis.refresh() # e.g. in setup_hook / on_ready + + await ctx.send(f"done {appmojis.get.success_mark}") + appmojis.emoji("success_mark").url + appmojis.list() + +``refresh()`` mirrors ``assets/emojis``: removing a file deletes its emoji. same name is left +alone — this lib does NOT diff image CONTENT, so to replace an image you rename the file or +delete + re-add. call ``refresh()`` at startup (or from a command) to re-sync on demand. + +fixed constants (not configurable) +---------------------------------- +the folder is always ``assets/emojis`` relative to the process cwd. accepted extensions are +``.png .jpg .jpeg .gif .webp``; max file size 256 KB; emoji names must be 2–32 chars of +``[A-Za-z0-9_]``; the application emoji cap is 2000. + +dot-access namespace +-------------------- +``self.get`` is a small namespace object whose ``__getattr__`` returns the emoji by name +(``AttributeError`` if absent). it is separate from the methods so an emoji named ``list`` or +``refresh`` can't shadow them. for names that aren't valid python identifiers use +:meth:`emoji`. + +injection / config contract +--------------------------- +inject the discord client; nothing is read from a global config. application-emoji methods +live on ``discord.Client`` (``fetch_application_emojis`` / ``create_application_emoji``, added +in discord.py 2.5), so this needs ``discord.py>=2.5``. + +error contract (fail-loud) +-------------------------- +nothing is swallowed. an invalid emoji name (bad filename) or an oversized file raises +``ValueError`` naming the file. exceeding the app emoji cap raises before any create. discord +API errors (``Forbidden`` / ``HTTPException``) propagate UNWRAPPED — a partial sync never +hides behind a silent success. :meth:`emoji` / ``get.`` for an unsynced name raise +``KeyError`` / ``AttributeError``. +""" +from __future__ import annotations + +import logging +import os +import re +from dataclasses import dataclass, field + +import discord + +log = logging.getLogger(__name__) + +EMOJI_DIR = "assets/emojis" +VALID_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp") +MAX_EMOJI_BYTES = 256 * 1024 +MAX_APP_EMOJIS = 2000 + +_NAME_RE = re.compile(r"^[A-Za-z0-9_]{2,32}$") + + +class DPYAppEmojisError(Exception): + """base error for dpy_appemojis-specific faults""" + + +@dataclass +class RefreshResult: + """outcome of a :meth:`DPYAppEmojis.refresh` — which emoji names were created, deleted, + or left unchanged this pass""" + + created: list[str] = field(default_factory=list) + deleted: list[str] = field(default_factory=list) + unchanged: list[str] = field(default_factory=list) + + +class _EmojiAccess: + """dot-access proxy over the synced emoji map; kept off the main class so emoji names + never collide with methods""" + + def __init__(self, owner: "DPYAppEmojis") -> None: + self._owner = owner + + def __getattr__(self, name: str) -> discord.Emoji: + """return the synced emoji by name, or raise AttributeError if not synced""" + emojis = self._owner._emojis + if name in emojis: + return emojis[name] + raise AttributeError(f"no synced application emoji named '{name}' (call refresh first)") + + +class DPYAppEmojis: + """mirror the assets/emojis folder onto the bot's application emojis""" + + def __init__(self, client: discord.Client) -> None: + """inject the client; emojis resolve against the application""" + self._client = client + self._emojis: dict[str, discord.Emoji] = {} + self._access = _EmojiAccess(self) + + async def refresh(self) -> RefreshResult: + """sync app emojis to mirror assets/emojis: create new files, delete removed ones, + leave same-name emojis untouched; raises on invalid name, oversized file, cap + overflow, or any discord API error""" + existing = {emoji.name: emoji for emoji in await self._client.fetch_application_emojis()} + desired = self._scan_folder() + + result = RefreshResult() + current = dict(existing) + + to_create = [name for name in desired if name not in existing] + if len(existing) + len(to_create) > MAX_APP_EMOJIS: + raise DPYAppEmojisError( + f"refresh would need {len(existing) + len(to_create)} application emojis, " + f"over the cap of {MAX_APP_EMOJIS}" + ) + + for name in to_create: + with open(desired[name], "rb") as fh: + image = fh.read() + try: + emoji = await self._client.create_application_emoji(name=name, image=image) + except (discord.Forbidden, discord.HTTPException): + log.error("dpy_appemojis: creating application emoji '%s' failed", name) + raise + current[name] = emoji + result.created.append(name) + + for name, emoji in list(existing.items()): + if name not in desired: + try: + await emoji.delete() + except (discord.Forbidden, discord.HTTPException): + log.error("dpy_appemojis: deleting application emoji '%s' failed", name) + raise + current.pop(name, None) + result.deleted.append(name) + + result.unchanged = [name for name in desired if name in existing] + self._emojis = current + return result + + def emoji(self, name: str) -> discord.Emoji: + """return the app emoji by name; raises KeyError if not synced (call refresh first)""" + return self._emojis[name] + + def list(self) -> list[str]: + """return the names of currently-synced app emojis""" + return list(self._emojis) + + @property + def all(self) -> dict[str, discord.Emoji]: + """map of {name: Emoji} for all synced app emojis""" + return dict(self._emojis) + + @property + def get(self) -> _EmojiAccess: + """dot-access namespace: ``self.get.success_mark`` -> the Emoji""" + return self._access + + def _scan_folder(self) -> dict[str, str]: + """scan EMOJI_DIR for valid image files -> {name: path}; raises ValueError on a bad + name or an oversized file""" + desired: dict[str, str] = {} + if not os.path.isdir(EMOJI_DIR): + return desired + + for entry in sorted(os.listdir(EMOJI_DIR)): + path = os.path.join(EMOJI_DIR, entry) + if not os.path.isfile(path): + continue + stem, ext = os.path.splitext(entry) + if ext.lower() not in VALID_EXTS: + continue + if not _NAME_RE.match(stem): + raise ValueError(f"invalid emoji name '{stem}' from {path}") + size = os.path.getsize(path) + if size > MAX_EMOJI_BYTES: + raise ValueError( + f"emoji file {path} is {size} bytes, over the {MAX_EMOJI_BYTES}-byte limit" + ) + desired[stem] = path + return desired