From d59bdcc9b90caed8170688d6c5200dc22e365d17 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Fri, 3 Jul 2026 15:32:16 -0400 Subject: [PATCH] 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 --- README.md | 27 +++++++++-- pyproject.toml | 2 +- src/dpy_appemojis/__init__.py | 2 +- src/dpy_appemojis/dpy_appemojis.py | 78 ++++++++++++++++++++++++------ 4 files changed, 88 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2acae01..884bfc8 100644 --- a/README.md +++ b/README.md @@ -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.` 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.` for a name that wasn't synced raise `KeyError` / `AttributeError` +(call `refresh()` first). ## Versioning diff --git a/pyproject.toml b/pyproject.toml index cc221cc..7f99d9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/dpy_appemojis/__init__.py b/src/dpy_appemojis/__init__.py index 3e83e29..5ba9404 100644 --- a/src/dpy_appemojis/__init__.py +++ b/src/dpy_appemojis/__init__.py @@ -12,7 +12,7 @@ from .dpy_appemojis import ( RefreshResult, ) -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "DPYAppEmojis", diff --git a/src/dpy_appemojis/dpy_appemojis.py b/src/dpy_appemojis/dpy_appemojis.py index ea5c36f..5067be3 100644 --- a/src/dpy_appemojis/dpy_appemojis.py +++ b/src/dpy_appemojis/dpy_appemojis.py @@ -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.`` 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.`` 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