raycord 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
raycord/__init__.py ADDED
@@ -0,0 +1,310 @@
1
+ """raycord - a raylib-simple wrapper around discord.py.
2
+
3
+ Quick start::
4
+
5
+ import raycord
6
+
7
+ raycord.init("YOUR_TOKEN", prefix="!")
8
+
9
+ @raycord.on_ready
10
+ def ready():
11
+ print("online as", raycord.user())
12
+
13
+ @raycord.command("ping")
14
+ def ping(ctx):
15
+ raycord.reply(ctx, "pong")
16
+
17
+ raycord.run()
18
+
19
+ Everything is a plain function; nothing needs ``await`` unless you want to
20
+ wait for a result (every helper returns a :class:`RayTask`).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ import discord
28
+ from discord import app_commands
29
+ from discord.ext import commands
30
+
31
+ from ._callbacks import (
32
+ ensure_async,
33
+ is_context,
34
+ is_interaction,
35
+ resolve_channel,
36
+ resolve_guild,
37
+ resolve_member,
38
+ resolve_voice,
39
+ )
40
+ from ._task import RayTask
41
+ from .bot import (
42
+ RaycordAutoShardedBot,
43
+ RaycordBot,
44
+ add_listener,
45
+ application,
46
+ bot,
47
+ get_channel,
48
+ get_guild,
49
+ get_member,
50
+ get_prefix,
51
+ get_user,
52
+ guilds,
53
+ init,
54
+ is_ready,
55
+ make_bot,
56
+ reset,
57
+ run,
58
+ set_status,
59
+ shutdown,
60
+ sync,
61
+ user,
62
+ wait_until_ready,
63
+ )
64
+ from .checks import (
65
+ bot_has_permissions,
66
+ check,
67
+ cooldown,
68
+ dm_only,
69
+ guild_only,
70
+ has_any_role,
71
+ has_guild_permissions,
72
+ has_permissions,
73
+ has_role,
74
+ is_owner,
75
+ nsfw_only,
76
+ )
77
+ from .cogs import Cog, add_cog, cog, cogs, reload_cog, remove_cog
78
+ from .commands_api import (
79
+ SlashGroup,
80
+ autocomplete,
81
+ choices,
82
+ command,
83
+ describe,
84
+ group,
85
+ message_command,
86
+ rename,
87
+ slash,
88
+ slash_group,
89
+ user_command,
90
+ )
91
+ from .errors import (
92
+ AlreadyInitialized,
93
+ NotInitialized,
94
+ NotReady,
95
+ RaycordError,
96
+ TargetError,
97
+ )
98
+ from .events import *
99
+ from .events import on, on_app_error
100
+ from .guild import (
101
+ ban,
102
+ create_category,
103
+ create_channel,
104
+ create_emoji,
105
+ create_event,
106
+ create_forum,
107
+ create_forum_post,
108
+ create_role,
109
+ create_sticker,
110
+ create_text_channel,
111
+ create_thread,
112
+ create_voice_channel,
113
+ kick,
114
+ unban,
115
+ )
116
+ from .intents import PRESETS, intents
117
+ from .messages import (
118
+ dm,
119
+ fetch_message,
120
+ pin,
121
+ react,
122
+ reply,
123
+ send,
124
+ )
125
+ from .ui import (
126
+ RayModal,
127
+ button,
128
+ field,
129
+ modal,
130
+ on_button,
131
+ on_modal,
132
+ on_select,
133
+ select,
134
+ show_modal,
135
+ view,
136
+ )
137
+ from .voice import (
138
+ is_playing,
139
+ join_voice,
140
+ leave_voice,
141
+ pause_voice,
142
+ play,
143
+ resume_voice,
144
+ stop_voice,
145
+ )
146
+
147
+ __version__ = "0.1.0"
148
+
149
+ Intents = discord.Intents
150
+
151
+ # ``edit`` and ``delete`` dispatch to the message helpers for messages and
152
+ # interactions, and to the generic guild helpers for everything else.
153
+ from . import guild as _guild # noqa: E402
154
+ from . import messages as _messages # noqa: E402
155
+
156
+
157
+ def edit(target: Any, **fields: Any) -> RayTask:
158
+ """Edit a message or interaction reply, or any other editable object."""
159
+ if isinstance(target, (discord.Message, discord.Interaction, commands.Context)):
160
+ return _messages.edit(target, **fields)
161
+ return _guild.edit(target, **fields)
162
+
163
+
164
+ def delete(target: Any, *, reason: Any = None) -> RayTask:
165
+ """Delete a message or interaction reply, or any other deletable object."""
166
+ if isinstance(target, (discord.Message, discord.Interaction, commands.Context)):
167
+ return _messages.delete(target)
168
+ return _guild.delete(target, reason=reason)
169
+
170
+
171
+ # Friendly aliases: raycord reads like a small game loop.
172
+ start = run
173
+ launch = run
174
+ stop = shutdown
175
+
176
+
177
+ __all__ = [
178
+ # modules and core types
179
+ "discord",
180
+ "commands",
181
+ "app_commands",
182
+ "RayTask",
183
+ "RaycordError",
184
+ "AlreadyInitialized",
185
+ "NotInitialized",
186
+ "NotReady",
187
+ "TargetError",
188
+ "__version__",
189
+ # lifecycle
190
+ "init",
191
+ "run",
192
+ "start",
193
+ "launch",
194
+ "shutdown",
195
+ "stop",
196
+ "reset",
197
+ "bot",
198
+ "make_bot",
199
+ "RaycordBot",
200
+ "RaycordAutoShardedBot",
201
+ "is_ready",
202
+ "wait_until_ready",
203
+ "sync",
204
+ "add_listener",
205
+ "set_status",
206
+ "user",
207
+ "application",
208
+ "guilds",
209
+ "get_guild",
210
+ "get_channel",
211
+ "get_user",
212
+ "get_member",
213
+ "get_prefix",
214
+ # intents
215
+ "intents",
216
+ "Intents",
217
+ "PRESETS",
218
+ # commands
219
+ "command",
220
+ "slash",
221
+ "slash_group",
222
+ "SlashGroup",
223
+ "group",
224
+ "user_command",
225
+ "message_command",
226
+ "describe",
227
+ "rename",
228
+ "autocomplete",
229
+ "choices",
230
+ # checks
231
+ "check",
232
+ "cooldown",
233
+ "has_permissions",
234
+ "has_guild_permissions",
235
+ "bot_has_permissions",
236
+ "has_role",
237
+ "has_any_role",
238
+ "is_owner",
239
+ "guild_only",
240
+ "dm_only",
241
+ "nsfw_only",
242
+ # events
243
+ "on",
244
+ "on_app_error",
245
+ # messages
246
+ "send",
247
+ "reply",
248
+ "edit",
249
+ "delete",
250
+ "react",
251
+ "pin",
252
+ "dm",
253
+ "fetch_message",
254
+ # ui
255
+ "view",
256
+ "button",
257
+ "select",
258
+ "field",
259
+ "modal",
260
+ "RayModal",
261
+ "show_modal",
262
+ "on_button",
263
+ "on_select",
264
+ "on_modal",
265
+ # voice
266
+ "join_voice",
267
+ "leave_voice",
268
+ "play",
269
+ "stop_voice",
270
+ "pause_voice",
271
+ "resume_voice",
272
+ "is_playing",
273
+ # guild
274
+ "create_channel",
275
+ "create_text_channel",
276
+ "create_voice_channel",
277
+ "create_category",
278
+ "create_forum",
279
+ "create_thread",
280
+ "create_forum_post",
281
+ "create_role",
282
+ "create_emoji",
283
+ "create_sticker",
284
+ "create_event",
285
+ "kick",
286
+ "ban",
287
+ "unban",
288
+ # cogs
289
+ "Cog",
290
+ "add_cog",
291
+ "remove_cog",
292
+ "reload_cog",
293
+ "cog",
294
+ "cogs",
295
+ # helpers
296
+ "ensure_async",
297
+ "resolve_member",
298
+ "resolve_guild",
299
+ "resolve_channel",
300
+ "resolve_voice",
301
+ "is_interaction",
302
+ "is_context",
303
+ ]
304
+
305
+ # Pull the generated event decorators (on_ready, on_message, ...) into __all__.
306
+ __all__ += [
307
+ name
308
+ for name in dir()
309
+ if name.startswith("on_") and name not in __all__ and not name.startswith("__")
310
+ ]
raycord/_callbacks.py ADDED
@@ -0,0 +1,141 @@
1
+ """Helpers that normalize user callbacks and resolve Discord targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ import typing
8
+ from typing import Any, Optional
9
+
10
+ import discord
11
+ from discord.ext import commands as dpy_commands
12
+
13
+ from .errors import TargetError
14
+
15
+
16
+ def ensure_async(fn: Any) -> Any:
17
+ """Return an async callable that behaves like ``fn``.
18
+
19
+ Works for plain functions, bound methods and already-async callables. The
20
+ wrapper keeps ``fn``'s signature (via ``functools.wraps``) so discord.py's
21
+ converters and slash-command parameter extraction still work, and it hides
22
+ class membership so discord.py treats bound methods as plain callables.
23
+ """
24
+ @functools.wraps(fn)
25
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
26
+ result = fn(*args, **kwargs)
27
+ if inspect.isawaitable(result):
28
+ return await result
29
+ return result
30
+
31
+ # Resolve string annotations now, using the original module's globals.
32
+ try:
33
+ hints = typing.get_type_hints(fn, include_extras=True)
34
+ except Exception: # pragma: no cover - exotic annotations
35
+ hints = {}
36
+ merged = dict(getattr(fn, "__annotations__", {}) or {})
37
+ merged.update(hints)
38
+ if merged:
39
+ wrapper.__annotations__ = merged
40
+
41
+ # Prevent discord.py from treating the wrapper as a class method.
42
+ wrapper.__qualname__ = wrapper.__name__
43
+
44
+ # Copy flags such as app_commands.describe metadata set on the original.
45
+ source = getattr(fn, "__dict__", None)
46
+ if source is None and hasattr(fn, "__func__"):
47
+ source = getattr(fn.__func__, "__dict__", None)
48
+ if source:
49
+ wrapper.__dict__.update(source)
50
+
51
+ return wrapper
52
+
53
+
54
+ def is_interaction(obj: Any) -> bool:
55
+ return isinstance(obj, discord.Interaction)
56
+
57
+
58
+ def is_context(obj: Any) -> bool:
59
+ return isinstance(obj, dpy_commands.Context)
60
+
61
+
62
+ def is_class_method(fn: Any) -> bool:
63
+ """Return ``True`` for functions defined directly in a class body.
64
+
65
+ Nested functions (``outer.<locals>.fn``) are not class methods, so they can
66
+ be registered immediately just like module-level functions.
67
+ """
68
+ qualname = getattr(fn, "__qualname__", getattr(fn, "__name__", ""))
69
+ parts = qualname.split(".")
70
+ if len(parts) < 2:
71
+ return False
72
+ return parts[-2] != "<locals>"
73
+
74
+
75
+ def resolve_member(obj: Any) -> Optional[discord.abc.User]:
76
+ """Best-effort "who triggered this": author, user or the user itself."""
77
+ if obj is None:
78
+ return None
79
+ if isinstance(obj, (discord.Member, discord.User)):
80
+ return obj
81
+ author = getattr(obj, "author", None)
82
+ if author is not None:
83
+ return author
84
+ user = getattr(obj, "user", None)
85
+ if user is not None:
86
+ return user
87
+ return None
88
+
89
+
90
+ def resolve_guild(obj: Any) -> Optional[discord.Guild]:
91
+ """Best-effort guild lookup for contexts, interactions and messages."""
92
+ if obj is None:
93
+ return None
94
+ if isinstance(obj, discord.Guild):
95
+ return obj
96
+ guild = getattr(obj, "guild", None)
97
+ if guild is not None:
98
+ return guild
99
+ channel = getattr(obj, "channel", None)
100
+ if channel is not None:
101
+ return getattr(channel, "guild", None)
102
+ member = resolve_member(obj)
103
+ if member is not None:
104
+ return getattr(member, "guild", None)
105
+ return None
106
+
107
+
108
+ def resolve_channel(obj: Any) -> Optional[Any]:
109
+ """Best-effort channel lookup for contexts, interactions and messages."""
110
+ if obj is None:
111
+ return None
112
+ if isinstance(obj, discord.abc.GuildChannel):
113
+ return obj
114
+ channel = getattr(obj, "channel", None)
115
+ if channel is not None:
116
+ return channel
117
+ return None
118
+
119
+
120
+ def resolve_voice(obj: Any) -> Optional[Any]:
121
+ """Return the voice *channel* the target's author is sitting in."""
122
+ member = resolve_member(obj)
123
+ if member is None:
124
+ return None
125
+ voice = getattr(member, "voice", None)
126
+ if voice is None:
127
+ return None
128
+ return getattr(voice, "channel", None)
129
+
130
+
131
+ def require_bot(obj: Any = None) -> Any:
132
+ """Return a bot instance from ``obj`` or from the global state."""
133
+ from . import _state
134
+
135
+ if obj is not None:
136
+ bot = getattr(obj, "bot", None) or getattr(obj, "client", None)
137
+ if bot is not None:
138
+ return bot
139
+ if _state.state.bot is None:
140
+ raise TargetError("no bot yet; call raycord.init(token) first")
141
+ return _state.state.bot
raycord/_state.py ADDED
@@ -0,0 +1,74 @@
1
+ """Global, raylib-style state for raycord.
2
+
3
+ raylib keeps one implicit context; raycord does the same. There is a single
4
+ :data:`state` object holding the bot, the event loop and every registration
5
+ that has to be replayed once the bot is ready.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ from typing import Any, Callable, Dict, List, Optional
12
+
13
+
14
+ class State:
15
+ """The implicit raycord context."""
16
+
17
+ def __init__(self) -> None:
18
+ self.bot: Any = None
19
+ self.loop: Any = None
20
+ self.token: Optional[str] = None
21
+ self.prefix: Any = None
22
+ self.config: Dict[str, Any] = {}
23
+ self.registrations: List[Callable[[Any], Any]] = []
24
+ self.flushed = False
25
+ self.ready = False
26
+ self.closed = False
27
+ # Global UI routing tables: custom_id -> handler.
28
+ self.ui_handlers: Dict[str, Dict[str, Callable[..., Any]]] = {
29
+ "button": {},
30
+ "select": {},
31
+ "modal": {},
32
+ }
33
+ # name -> record describing everything a loaded cog created.
34
+ self.cogs: Dict[str, Dict[str, Any]] = {}
35
+
36
+ def reset(self) -> None:
37
+ """Forget everything. Mostly useful for tests and restarts."""
38
+ self.bot = None
39
+ self.loop = None
40
+ self.token = None
41
+ self.prefix = None
42
+ self.config = {}
43
+ self.registrations = []
44
+ self.flushed = False
45
+ self.ready = False
46
+ self.closed = False
47
+ self.ui_handlers = {"button": {}, "select": {}, "modal": {}}
48
+ self.cogs = {}
49
+
50
+
51
+ state = State()
52
+
53
+
54
+ def when_bot(fn: Callable[[Any], Any]) -> Any:
55
+ """Run ``fn(bot)`` now if the bot exists, otherwise when it is ready.
56
+
57
+ ``fn`` may return an awaitable; :func:`flush` will await it.
58
+ """
59
+ if state.flushed and state.bot is not None:
60
+ return fn(state.bot)
61
+ state.registrations.append(fn)
62
+ return None
63
+
64
+
65
+ async def flush(bot: Any) -> None:
66
+ """Replay every pending registration against ``bot``."""
67
+ state.bot = bot
68
+ pending, state.registrations = state.registrations, []
69
+ for fn in pending:
70
+ result = fn(bot)
71
+ if inspect.isawaitable(result):
72
+ await result
73
+ state.flushed = True
74
+ state.closed = False
raycord/_task.py ADDED
@@ -0,0 +1,112 @@
1
+ """A tiny "started coroutine" object, so raycord functions never need ``await``.
2
+
3
+ raylib never asks you to wait; you just call the function. ``RayTask`` lets a
4
+ raycord helper start an async discord.py call right away and still be usable
5
+ from three situations:
6
+
7
+ * fire and forget ``raycord.send(channel, "hi")``
8
+ * blocking sync code ``raycord.send(channel, "hi").result()``
9
+ * async code ``await raycord.send(channel, "hi")``
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import concurrent.futures
16
+ import inspect
17
+ from typing import Any, Optional
18
+
19
+ from . import _state
20
+
21
+
22
+ class RayTask:
23
+ """Handle for a discord.py coroutine that already started running."""
24
+
25
+ def __init__(self, coro: Any, loop: Any = None) -> None:
26
+ self._coro = coro
27
+ self._async: Optional[asyncio.Future] = None
28
+ self._future: Optional[concurrent.futures.Future] = None
29
+
30
+ loop = loop or _state.state.loop
31
+
32
+ if not inspect.isawaitable(coro):
33
+ done: concurrent.futures.Future = concurrent.futures.Future()
34
+ done.set_result(coro)
35
+ self._future = done
36
+ return
37
+
38
+ try:
39
+ running = asyncio.get_running_loop()
40
+ except RuntimeError:
41
+ running = None
42
+
43
+ if running is not None:
44
+ # Called from inside the bot loop; use it directly.
45
+ self._async = asyncio.ensure_future(coro)
46
+ elif loop is not None and loop.is_running():
47
+ # Called from another thread; hand the work to the bot loop.
48
+ self._future = asyncio.run_coroutine_threadsafe(coro, loop) # type: ignore[arg-type]
49
+ else:
50
+ # No bot around (yet). Best effort: run it to completion now.
51
+ self._future = concurrent.futures.Future()
52
+ try:
53
+ self._future.set_result(asyncio.run(coro)) # type: ignore[arg-type]
54
+ except BaseException as exc: # pragma: no cover - defensive
55
+ self._future.set_exception(exc)
56
+
57
+ # -- inspection ------------------------------------------------------
58
+ def done(self) -> bool:
59
+ """Return ``True`` when the underlying work has finished."""
60
+ if self._async is not None:
61
+ return self._async.done()
62
+ assert self._future is not None
63
+ return self._future.done()
64
+
65
+ def cancelled(self) -> bool:
66
+ """Return ``True`` if the work was cancelled."""
67
+ if self._async is not None:
68
+ return self._async.cancelled()
69
+ assert self._future is not None
70
+ return self._future.cancelled()
71
+
72
+ def cancel(self) -> bool:
73
+ """Attempt to cancel the work."""
74
+ if self._async is not None:
75
+ return self._async.cancel()
76
+ assert self._future is not None
77
+ return self._future.cancel()
78
+
79
+ def result(self, timeout: Optional[float] = None) -> Any:
80
+ """Block until the work finishes and return its value.
81
+
82
+ Only safe outside the event loop thread. From async code use
83
+ ``await`` on the task instead.
84
+ """
85
+ if self._async is not None:
86
+ if not self._async.done():
87
+ raise RuntimeError(
88
+ "result() cannot block the event loop; await the RayTask instead"
89
+ )
90
+ return self._async.result()
91
+ assert self._future is not None
92
+ return self._future.result(timeout)
93
+
94
+ def exception(self, timeout: Optional[float] = None) -> Optional[BaseException]:
95
+ """Return the exception raised by the work, if any."""
96
+ if self._async is not None:
97
+ if not self._async.done():
98
+ raise RuntimeError("task is not done; await the RayTask instead")
99
+ return self._async.exception()
100
+ assert self._future is not None
101
+ return self._future.exception(timeout)
102
+
103
+ # -- awaiting --------------------------------------------------------
104
+ def __await__(self):
105
+ if self._async is not None:
106
+ return self._async.__await__()
107
+ assert self._future is not None
108
+ return asyncio.wrap_future(self._future).__await__()
109
+
110
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
111
+ state = "pending" if not self.done() else "done"
112
+ return f"<RayTask {state}>"