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
|
## 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
@@ -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 = [
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from .dpy_appemojis import (
|
|||||||
RefreshResult,
|
RefreshResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "0.1.1"
|
__version__ = "0.1.2"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DPYAppEmojis",
|
"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
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user