fix: fail loud on missing emoji dir, stem collisions, and corrupt images
refresh() previously treated a missing assets/emojis folder (wrong cwd,
missing WorkingDirectory=/WORKDIR) as an empty one, silently deleting
every application emoji on the app every restart with no signal and no
way back (recreated emojis get new IDs). _scan_folder now raises
DPYAppEmojisError naming the absent path instead; a present-but-empty
folder still mirrors as before.
Two files sharing a name stem across extensions (check.png + check.gif)
used to silently collapse to one synced emoji via last-write-wins,
breaking the documented delete+re-add replace workflow with no error.
_scan_folder now raises DPYAppEmojisError naming both colliding files.
A valid extension with unrecognized magic bytes (truncated download,
mismatched format) made discord.py raise an anonymous
ValueError('Unsupported image type given') from create_application_emoji,
which the lib's (Forbidden, HTTPException) catch didn't cover, leaving
no way to tell which file in a large folder was broken. _scan_folder now
pre-flights the same magic-byte check discord.py itself uses and raises
ValueError naming the offending file before any create is attempted.
Also collapses the redundant Forbidden member in the create/delete except
clauses (Forbidden already subclasses HTTPException); log-then-raise
behavior is unchanged.
Bump 0.1.0 -> 0.1.1.
Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
@@ -34,6 +34,12 @@ appmojis.list() # ["success_mark", ...]
|
||||
Call `refresh()` at startup, or from a command, to re-sync on demand — the lib adds no
|
||||
commands of its own.
|
||||
|
||||
A **missing** `assets/emojis` folder is not the same as an **empty** one: if the folder
|
||||
doesn't exist (wrong cwd, missing `WorkingDirectory=`/`WORKDIR`), `refresh()` raises
|
||||
`DPYAppEmojisError` naming the path instead of mirroring an empty desired set — which would
|
||||
otherwise delete every application emoji on the next restart. An empty-but-present folder is
|
||||
still honored as "delete everything."
|
||||
|
||||
## Dot-access vs `emoji()`
|
||||
|
||||
`self.get` is a namespace whose attribute lookup returns the emoji by name. It lives apart
|
||||
@@ -60,11 +66,22 @@ targets **`discord.py>=2.5`**.
|
||||
## Contract (fail-loud)
|
||||
|
||||
The module docstring (`help(dpy_appemojis)` / IDE hover) is the source of truth. Nothing is
|
||||
swallowed: an invalid emoji name from a bad filename or an oversized file raises `ValueError`
|
||||
naming the file; exceeding the 2000 cap raises `DPYAppEmojisError` **before** any create; a
|
||||
discord API error (`Forbidden` / `HTTPException`) propagates **unwrapped** so a partial sync
|
||||
never hides behind a silent success. `emoji(name)` / `get.<name>` for a name that wasn't
|
||||
synced raise `KeyError` / `AttributeError` (call `refresh()` first).
|
||||
swallowed:
|
||||
|
||||
- an invalid emoji name from a bad filename, an oversized file, or a file whose bytes aren't
|
||||
a recognized PNG/JPEG/GIF/WEBP image (corrupt, truncated, or mismatched extension) raises
|
||||
`ValueError` naming the file
|
||||
- two files sharing a name stem across extensions (e.g. `check.png` and `check.gif`) raise
|
||||
`DPYAppEmojisError` naming **both** files, instead of silently syncing one and dropping the
|
||||
other
|
||||
- a missing `assets/emojis` folder raises `DPYAppEmojisError` naming the path (see above)
|
||||
rather than mirroring an empty set
|
||||
- exceeding the 2000 cap raises `DPYAppEmojisError` **before** any create
|
||||
- a discord API error (`HTTPException`, which covers `Forbidden`) propagates **unwrapped** so
|
||||
a partial sync never hides behind a silent success
|
||||
|
||||
`emoji(name)` / `get.<name>` for a name that wasn't synced raise `KeyError` / `AttributeError`
|
||||
(call `refresh()` first).
|
||||
|
||||
## Versioning
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "dpy_appemojis"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
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 = [
|
||||
|
||||
@@ -12,7 +12,7 @@ from .dpy_appemojis import (
|
||||
RefreshResult,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
__all__ = [
|
||||
"DPYAppEmojis",
|
||||
|
||||
@@ -27,6 +27,12 @@ the folder is always ``assets/emojis`` relative to the process cwd. accepted ext
|
||||
``.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.
|
||||
|
||||
a MISSING folder is not the same as an EMPTY one: an absent ``assets/emojis`` (wrong cwd,
|
||||
missing ``WorkingDirectory=``/``WORKDIR``) raises :class:`DPYAppEmojisError` instead of
|
||||
silently mirroring an empty desired set — the latter would delete every application emoji
|
||||
on the next :meth:`refresh`. an empty-but-present folder is a legitimate "delete everything"
|
||||
signal and is honored as before.
|
||||
|
||||
dot-access namespace
|
||||
--------------------
|
||||
``self.get`` is a small namespace object whose ``__getattr__`` returns the emoji by name
|
||||
@@ -42,11 +48,16 @@ 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``.
|
||||
nothing is swallowed. an invalid emoji name (bad filename), an oversized file, or a file
|
||||
whose bytes aren't a recognized image (corrupt/truncated/mismatched extension) raises
|
||||
``ValueError`` naming the file. two files sharing a name stem across extensions (e.g.
|
||||
``check.png`` and ``check.gif``) raise :class:`DPYAppEmojisError` naming both, instead of
|
||||
silently syncing one and dropping the other. a missing ``assets/emojis`` folder raises
|
||||
:class:`DPYAppEmojisError` naming the path (see above) rather than mirroring an empty set.
|
||||
exceeding the app emoji cap raises before any create. discord API errors (``HTTPException``,
|
||||
which covers ``Forbidden``) 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
|
||||
|
||||
@@ -67,6 +78,20 @@ MAX_APP_EMOJIS = 2000
|
||||
_NAME_RE = re.compile(r"^[A-Za-z0-9_]{2,32}$")
|
||||
|
||||
|
||||
def _looks_like_image(header: bytes) -> bool:
|
||||
"""mirror discord.py's own magic-byte sniff (png/jpeg/gif/webp) so a bad file is caught
|
||||
here, with the offending path, instead of as an anonymous ValueError from create"""
|
||||
if header.startswith(b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"):
|
||||
return True
|
||||
if header[0:3] == b"\xff\xd8\xff" or header[6:10] in (b"JFIF", b"Exif"):
|
||||
return True
|
||||
if header.startswith((b"\x47\x49\x46\x38\x37\x61", b"\x47\x49\x46\x38\x39\x61")):
|
||||
return True
|
||||
if header.startswith(b"RIFF") and header[8:12] == b"WEBP":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class DPYAppEmojisError(Exception):
|
||||
"""base error for dpy_appemojis-specific faults"""
|
||||
|
||||
@@ -107,8 +132,16 @@ class DPYAppEmojis:
|
||||
|
||||
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"""
|
||||
leave same-name emojis untouched
|
||||
|
||||
Raises:
|
||||
DPYAppEmojisError: EMOJI_DIR does not exist, two files share a name stem across
|
||||
extensions, or the sync would exceed the application emoji cap
|
||||
ValueError: a file has an invalid name, is oversized, or isn't a recognized
|
||||
image format
|
||||
discord.HTTPException: any discord API error (includes Forbidden), propagated
|
||||
unwrapped
|
||||
"""
|
||||
existing = {emoji.name: emoji for emoji in await self._client.fetch_application_emojis()}
|
||||
desired = self._scan_folder()
|
||||
|
||||
@@ -127,7 +160,7 @@ class DPYAppEmojis:
|
||||
image = fh.read()
|
||||
try:
|
||||
emoji = await self._client.create_application_emoji(name=name, image=image)
|
||||
except (discord.Forbidden, discord.HTTPException):
|
||||
except discord.HTTPException:
|
||||
log.error("dpy_appemojis: creating application emoji '%s' failed", name)
|
||||
raise
|
||||
current[name] = emoji
|
||||
@@ -137,7 +170,7 @@ class DPYAppEmojis:
|
||||
if name not in desired:
|
||||
try:
|
||||
await emoji.delete()
|
||||
except (discord.Forbidden, discord.HTTPException):
|
||||
except discord.HTTPException:
|
||||
log.error("dpy_appemojis: deleting application emoji '%s' failed", name)
|
||||
raise
|
||||
current.pop(name, None)
|
||||
@@ -166,12 +199,21 @@ class DPYAppEmojis:
|
||||
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
|
||||
"""scan EMOJI_DIR for valid image files -> {name: path}
|
||||
|
||||
Raises:
|
||||
DPYAppEmojisError: EMOJI_DIR does not exist, or two files share a name stem
|
||||
across extensions (e.g. ``check.png`` and ``check.gif``)
|
||||
ValueError: a file has an invalid name, is oversized, or isn't a recognized
|
||||
image format
|
||||
"""
|
||||
if not os.path.isdir(EMOJI_DIR):
|
||||
raise DPYAppEmojisError(
|
||||
f"EMOJI_DIR '{EMOJI_DIR}' does not exist (relative to cwd {os.getcwd()!r}); "
|
||||
"refusing to mirror an empty desired set onto the application emojis"
|
||||
)
|
||||
|
||||
desired: dict[str, str] = {}
|
||||
for entry in sorted(os.listdir(EMOJI_DIR)):
|
||||
path = os.path.join(EMOJI_DIR, entry)
|
||||
if not os.path.isfile(path):
|
||||
@@ -186,5 +228,13 @@ class DPYAppEmojis:
|
||||
raise ValueError(
|
||||
f"emoji file {path} is {size} bytes, over the {MAX_EMOJI_BYTES}-byte limit"
|
||||
)
|
||||
with open(path, "rb") as fh:
|
||||
header = fh.read(16)
|
||||
if not _looks_like_image(header):
|
||||
raise ValueError(f"emoji file {path} is not a recognized PNG/JPEG/GIF/WEBP image")
|
||||
if stem in desired:
|
||||
raise DPYAppEmojisError(
|
||||
f"duplicate emoji name '{stem}' from both {desired[stem]} and {path}"
|
||||
)
|
||||
desired[stem] = path
|
||||
return desired
|
||||
|
||||
Reference in New Issue
Block a user