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:
@@ -8,7 +8,7 @@ source of truth: a file removed means its emoji is deleted.
|
||||
## 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
|
||||
@@ -79,10 +79,19 @@ swallowed:
|
||||
- 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
|
||||
- `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`
|
||||
(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
|
||||
|
||||
Tagged `vX.Y.Z`; pin a tag in your install line. Targets `discord.py>=2.5` (not
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
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."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
||||
@@ -12,7 +12,7 @@ from .dpy_appemojis import (
|
||||
RefreshResult,
|
||||
)
|
||||
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "0.1.2"
|
||||
|
||||
__all__ = [
|
||||
"DPYAppEmojis",
|
||||
|
||||
@@ -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
|
||||
: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``.
|
||||
which covers ``Forbidden``, and ``MissingApplicationID`` if the client's application_id
|
||||
isn't set yet) propagate UNWRAPPED — a partial sync never hides behind a silent success.
|
||||
: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
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -78,6 +85,12 @@ MAX_APP_EMOJIS = 2000
|
||||
_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:
|
||||
"""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"""
|
||||
@@ -141,9 +154,11 @@ class DPYAppEmojis:
|
||||
image format
|
||||
discord.HTTPException: any discord API error (includes Forbidden), propagated
|
||||
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()}
|
||||
desired = self._scan_folder()
|
||||
desired = await asyncio.to_thread(self._scan_folder)
|
||||
|
||||
result = RefreshResult()
|
||||
current = dict(existing)
|
||||
@@ -156,8 +171,7 @@ class DPYAppEmojis:
|
||||
)
|
||||
|
||||
for name in to_create:
|
||||
with open(desired[name], "rb") as fh:
|
||||
image = fh.read()
|
||||
image = await asyncio.to_thread(_read_file, desired[name])
|
||||
try:
|
||||
emoji = await self._client.create_application_emoji(name=name, image=image)
|
||||
except discord.HTTPException:
|
||||
|
||||
Reference in New Issue
Block a user