add package: pyproject + src (DPYAppEmojis, folder-mirror refresh, dot-access, fail-loud)
Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
@@ -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"]
|
||||
@@ -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__",
|
||||
]
|
||||
@@ -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.<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
|
||||
Reference in New Issue
Block a user