add package: pyproject + src (DPYAppEmojis, folder-mirror refresh, dot-access, fail-loud)

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-02 19:59:13 -04:00
parent 77cfc6a10a
commit 7d878845df
3 changed files with 231 additions and 0 deletions
+15
View File
@@ -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"]
+26
View File
@@ -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__",
]
+190
View File
@@ -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 232 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.<name>`` 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