fix: offload refresh() blocking I/O to a thread; document MissingApplicationID (v0.1.2)

refresh() did folder scan, magic-byte sniff, and per-file image reads directly
on the event loop. Offload via asyncio.to_thread so the loop stays responsive
during a large sync; behavior (validation, size cap, stem-collision and
missing-dir raises) is unchanged.

Also documents discord.MissingApplicationID in refresh()'s Raises contract -
it is a ClientException, not an HTTPException, so it was previously uncaught
by the existing except clauses and unlisted in the docstring.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-03 16:17:59 -04:00
parent d59bdcc9b9
commit 6fc4e14f3c
4 changed files with 32 additions and 9 deletions
+10 -1
View File
@@ -8,7 +8,7 @@ source of truth: a file removed means its emoji is deleted.
## Install ## Install
``` ```
dpy_appemojis @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_appemojis.git@v0.1.0 dpy_appemojis @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_appemojis.git@v0.1.2
``` ```
## Usage ## Usage
@@ -79,10 +79,19 @@ swallowed:
- exceeding the 2000 cap raises `DPYAppEmojisError` **before** any create - exceeding the 2000 cap raises `DPYAppEmojisError` **before** any create
- a discord API error (`HTTPException`, which covers `Forbidden`) propagates **unwrapped** so - a discord API error (`HTTPException`, which covers `Forbidden`) propagates **unwrapped** so
a partial sync never hides behind a silent success a partial sync never hides behind a silent success
- `MissingApplicationID` (raised if `refresh()` runs before the client's `application_id` is
set, e.g. before `on_ready`) also propagates **unwrapped** — it is a `ClientException`, not
an `HTTPException`, so it is never caught by an `except discord.HTTPException` clause
`emoji(name)` / `get.<name>` for a name that wasn't synced raise `KeyError` / `AttributeError` `emoji(name)` / `get.<name>` for a name that wasn't synced raise `KeyError` / `AttributeError`
(call `refresh()` first). (call `refresh()` first).
## Async stance
`refresh()` offloads its blocking filesystem work (folder scan, magic-byte sniff, per-file
image read) to a worker thread via `asyncio.to_thread`, so the event loop stays responsive
during a large sync. Only the discord API calls run on the loop directly.
## Versioning ## Versioning
Tagged `vX.Y.Z`; pin a tag in your install line. Targets `discord.py>=2.5` (not Tagged `vX.Y.Z`; pin a tag in your install line. Targets `discord.py>=2.5` (not
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dpy_appemojis" name = "dpy_appemojis"
version = "0.1.1" version = "0.1.2"
description = "Mirror a project folder onto the bot's application emojis for discord.py — folder is the source of truth. Config-free, injectable, installable." 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" requires-python = ">=3.10"
dependencies = [ dependencies = [
+1 -1
View File
@@ -12,7 +12,7 @@ from .dpy_appemojis import (
RefreshResult, RefreshResult,
) )
__version__ = "0.1.1" __version__ = "0.1.2"
__all__ = [ __all__ = [
"DPYAppEmojis", "DPYAppEmojis",
+20 -6
View File
@@ -55,12 +55,19 @@ whose bytes aren't a recognized image (corrupt/truncated/mismatched extension) r
silently syncing one and dropping the other. a missing ``assets/emojis`` folder raises 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. :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``, 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 which covers ``Forbidden``, and ``MissingApplicationID`` if the client's application_id
success. :meth:`emoji` / ``get.<name>`` for an unsynced name raise ``KeyError`` / isn't set yet) propagate UNWRAPPED — a partial sync never hides behind a silent success.
``AttributeError``. :meth:`emoji` / ``get.<name>`` for an unsynced name raise ``KeyError`` / ``AttributeError``.
async stance
------------
:meth:`refresh` offloads its blocking filesystem work (folder scan, per-file magic-byte
sniff, per-file image read) to a thread via ``asyncio.to_thread`` so the event loop stays
responsive during a large sync; only the discord API calls run on the loop directly.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import os import os
import re import re
@@ -78,6 +85,12 @@ MAX_APP_EMOJIS = 2000
_NAME_RE = re.compile(r"^[A-Za-z0-9_]{2,32}$") _NAME_RE = re.compile(r"^[A-Za-z0-9_]{2,32}$")
def _read_file(path: str) -> bytes:
"""read a file's full bytes; run off-loop via asyncio.to_thread"""
with open(path, "rb") as fh:
return fh.read()
def _looks_like_image(header: bytes) -> bool: 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 """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""" here, with the offending path, instead of as an anonymous ValueError from create"""
@@ -141,9 +154,11 @@ class DPYAppEmojis:
image format image format
discord.HTTPException: any discord API error (includes Forbidden), propagated discord.HTTPException: any discord API error (includes Forbidden), propagated
unwrapped unwrapped
discord.MissingApplicationID: the client's application_id is not set yet (e.g.
refresh() called before login completes), propagated unwrapped
""" """
existing = {emoji.name: emoji for emoji in await self._client.fetch_application_emojis()} existing = {emoji.name: emoji for emoji in await self._client.fetch_application_emojis()}
desired = self._scan_folder() desired = await asyncio.to_thread(self._scan_folder)
result = RefreshResult() result = RefreshResult()
current = dict(existing) current = dict(existing)
@@ -156,8 +171,7 @@ class DPYAppEmojis:
) )
for name in to_create: for name in to_create:
with open(desired[name], "rb") as fh: image = await asyncio.to_thread(_read_file, desired[name])
image = fh.read()
try: try:
emoji = await self._client.create_application_emoji(name=name, image=image) emoji = await self._client.create_application_emoji(name=name, image=image)
except discord.HTTPException: except discord.HTTPException: