pydis_core 10.3.0__tar.gz
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.
- pydis_core-10.3.0/LICENSE +21 -0
- pydis_core-10.3.0/PKG-INFO +31 -0
- pydis_core-10.3.0/README.md +3 -0
- pydis_core-10.3.0/pydis_core/__init__.py +15 -0
- pydis_core-10.3.0/pydis_core/_bot.py +314 -0
- pydis_core-10.3.0/pydis_core/async_stats.py +56 -0
- pydis_core-10.3.0/pydis_core/exts/__init__.py +4 -0
- pydis_core-10.3.0/pydis_core/site_api.py +156 -0
- pydis_core-10.3.0/pydis_core/utils/__init__.py +54 -0
- pydis_core-10.3.0/pydis_core/utils/_extensions.py +57 -0
- pydis_core-10.3.0/pydis_core/utils/_monkey_patches.py +72 -0
- pydis_core-10.3.0/pydis_core/utils/caching.py +65 -0
- pydis_core-10.3.0/pydis_core/utils/channel.py +57 -0
- pydis_core-10.3.0/pydis_core/utils/commands.py +37 -0
- pydis_core-10.3.0/pydis_core/utils/cooldown.py +221 -0
- pydis_core-10.3.0/pydis_core/utils/error_handling.py +35 -0
- pydis_core-10.3.0/pydis_core/utils/function.py +111 -0
- pydis_core-10.3.0/pydis_core/utils/interactions.py +121 -0
- pydis_core-10.3.0/pydis_core/utils/logging.py +64 -0
- pydis_core-10.3.0/pydis_core/utils/members.py +59 -0
- pydis_core-10.3.0/pydis_core/utils/paste_service.py +153 -0
- pydis_core-10.3.0/pydis_core/utils/regex.py +54 -0
- pydis_core-10.3.0/pydis_core/utils/scheduling.py +269 -0
- pydis_core-10.3.0/pyproject.toml +105 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Python Discord
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pydis_core
|
|
3
|
+
Version: 10.3.0
|
|
4
|
+
Summary: PyDis core provides core functionality and utility to the bots of the Python Discord community.
|
|
5
|
+
Home-page: https://pythondiscord.com/
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: bot,discord,discord.py
|
|
8
|
+
Author: Python Discord
|
|
9
|
+
Author-email: info@pythondiscord.com
|
|
10
|
+
Requires-Python: >=3.10.dev0,<3.12.dev0
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Provides-Extra: async-rediscache
|
|
19
|
+
Requires-Dist: aiodns (==3.0.0)
|
|
20
|
+
Requires-Dist: async-rediscache[fakeredis] (==1.0.0rc2) ; extra == "async-rediscache"
|
|
21
|
+
Requires-Dist: discord.py (==2.3.2)
|
|
22
|
+
Requires-Dist: pydantic (>=1.7.4,<3.0.0)
|
|
23
|
+
Requires-Dist: statsd (==4.0.1)
|
|
24
|
+
Project-URL: Documentation, https://bot-core.pythondiscord.com/
|
|
25
|
+
Project-URL: Repository, https://github.com/python-discord/bot-core
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# bot-core ![Version]
|
|
29
|
+
|
|
30
|
+
[Version]: https://img.shields.io/github/v/tag/python-discord/bot-core?label=latest&logo=version
|
|
31
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Useful utilities and tools for Discord bot development."""
|
|
2
|
+
|
|
3
|
+
from pydis_core import async_stats, exts, site_api, utils
|
|
4
|
+
from pydis_core._bot import BotBase, StartupError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
async_stats,
|
|
8
|
+
BotBase,
|
|
9
|
+
exts,
|
|
10
|
+
utils,
|
|
11
|
+
site_api,
|
|
12
|
+
StartupError,
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
__all__ = [module.__name__ for module in __all__]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import socket
|
|
3
|
+
import types
|
|
4
|
+
import warnings
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
import discord
|
|
9
|
+
from discord.ext import commands
|
|
10
|
+
|
|
11
|
+
from pydis_core.async_stats import AsyncStatsClient
|
|
12
|
+
from pydis_core.site_api import APIClient
|
|
13
|
+
from pydis_core.utils import scheduling
|
|
14
|
+
from pydis_core.utils._extensions import walk_extensions
|
|
15
|
+
from pydis_core.utils.logging import get_logger
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from async_rediscache import RedisSession
|
|
19
|
+
REDIS_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
RedisSession = object
|
|
22
|
+
REDIS_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
log = get_logger()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StartupError(Exception):
|
|
28
|
+
"""Exception class for startup errors."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, base: Exception):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.exception = base
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BotBase(commands.Bot):
|
|
36
|
+
"""A sub-class that implements many common features that Python Discord bots use."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*args,
|
|
41
|
+
guild_id: int,
|
|
42
|
+
allowed_roles: list,
|
|
43
|
+
http_session: aiohttp.ClientSession,
|
|
44
|
+
redis_session: RedisSession | None = None,
|
|
45
|
+
api_client: APIClient | None = None,
|
|
46
|
+
statsd_url: str | None = None,
|
|
47
|
+
**kwargs,
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialise the base bot instance.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
guild_id: The ID of the guild used for :func:`wait_until_guild_available`.
|
|
54
|
+
allowed_roles: A list of role IDs that the bot is allowed to mention.
|
|
55
|
+
http_session (aiohttp.ClientSession): The session to use for the bot.
|
|
56
|
+
redis_session: The `async_rediscache.RedisSession`_ to use for the bot.
|
|
57
|
+
api_client: The :obj:`pydis_core.site_api.APIClient` instance to use for the bot.
|
|
58
|
+
statsd_url: The URL of the statsd server to use for the bot. If not given,
|
|
59
|
+
a dummy statsd client will be created.
|
|
60
|
+
|
|
61
|
+
.. _async_rediscache.RedisSession: https://github.com/SebastiaanZ/async-rediscache#creating-a-redissession
|
|
62
|
+
"""
|
|
63
|
+
super().__init__(
|
|
64
|
+
*args,
|
|
65
|
+
allowed_roles=allowed_roles,
|
|
66
|
+
**kwargs,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self.guild_id = guild_id
|
|
70
|
+
self.http_session = http_session
|
|
71
|
+
self.api_client = api_client
|
|
72
|
+
self.statsd_url = statsd_url
|
|
73
|
+
|
|
74
|
+
if redis_session and not REDIS_AVAILABLE:
|
|
75
|
+
warnings.warn("redis_session kwarg passed, but async-rediscache not installed!", stacklevel=2)
|
|
76
|
+
elif redis_session:
|
|
77
|
+
self.redis_session = redis_session
|
|
78
|
+
|
|
79
|
+
self._resolver: aiohttp.AsyncResolver | None = None
|
|
80
|
+
self._connector: aiohttp.TCPConnector | None = None
|
|
81
|
+
|
|
82
|
+
self._statsd_timerhandle: asyncio.TimerHandle | None = None
|
|
83
|
+
self._guild_available: asyncio.Event | None = None
|
|
84
|
+
self._extension_loading_task: asyncio.Task | None = None
|
|
85
|
+
|
|
86
|
+
self.stats: AsyncStatsClient | None = None
|
|
87
|
+
|
|
88
|
+
self.all_extensions: frozenset[str] | None = None
|
|
89
|
+
|
|
90
|
+
def _connect_statsd(
|
|
91
|
+
self,
|
|
92
|
+
statsd_url: str,
|
|
93
|
+
loop: asyncio.AbstractEventLoop,
|
|
94
|
+
retry_after: int = 2,
|
|
95
|
+
attempt: int = 1
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Callback used to retry a connection to statsd if it should fail."""
|
|
98
|
+
if attempt >= 8:
|
|
99
|
+
log.error(
|
|
100
|
+
"Reached 8 attempts trying to reconnect AsyncStatsClient to %s. "
|
|
101
|
+
"Aborting and leaving the dummy statsd client in place.",
|
|
102
|
+
statsd_url,
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
self.stats = AsyncStatsClient(loop, statsd_url, 8125, prefix="bot")
|
|
108
|
+
except socket.gaierror:
|
|
109
|
+
log.warning(f"Statsd client failed to connect (Attempt(s): {attempt})")
|
|
110
|
+
# Use a fallback strategy for retrying, up to 8 times.
|
|
111
|
+
self._statsd_timerhandle = loop.call_later(
|
|
112
|
+
retry_after,
|
|
113
|
+
self._connect_statsd,
|
|
114
|
+
statsd_url,
|
|
115
|
+
retry_after * 2,
|
|
116
|
+
attempt + 1
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def _load_extensions(self, module: types.ModuleType) -> None:
|
|
120
|
+
"""Load all the extensions within the given module and save them to ``self.all_extensions``."""
|
|
121
|
+
log.info("Waiting for guild %d to be available before loading extensions.", self.guild_id)
|
|
122
|
+
|
|
123
|
+
await self.wait_until_guild_available()
|
|
124
|
+
log.info("Loading extensions...")
|
|
125
|
+
self.all_extensions = walk_extensions(module)
|
|
126
|
+
|
|
127
|
+
for extension in self.all_extensions:
|
|
128
|
+
scheduling.create_task(self.load_extension(extension))
|
|
129
|
+
|
|
130
|
+
async def _sync_app_commands(self) -> None:
|
|
131
|
+
"""Sync global & guild specific application commands after extensions are loaded."""
|
|
132
|
+
await self._extension_loading_task
|
|
133
|
+
await self.tree.sync()
|
|
134
|
+
await self.tree.sync(guild=discord.Object(self.guild_id))
|
|
135
|
+
|
|
136
|
+
async def load_extensions(self, module: types.ModuleType, sync_app_commands: bool = True) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Load all the extensions within the given ``module`` and save them to ``self.all_extensions``.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
sync_app_commands: Whether to sync app commands after all extensions are loaded.
|
|
142
|
+
"""
|
|
143
|
+
self._extension_loading_task = scheduling.create_task(self._load_extensions(module))
|
|
144
|
+
if sync_app_commands:
|
|
145
|
+
scheduling.create_task(self._sync_app_commands())
|
|
146
|
+
|
|
147
|
+
def _add_root_aliases(self, command: commands.Command) -> None:
|
|
148
|
+
"""Recursively add root aliases for ``command`` and any of its subcommands."""
|
|
149
|
+
if isinstance(command, commands.Group):
|
|
150
|
+
for subcommand in command.commands:
|
|
151
|
+
self._add_root_aliases(subcommand)
|
|
152
|
+
|
|
153
|
+
for alias in getattr(command, "root_aliases", ()):
|
|
154
|
+
if alias in self.all_commands:
|
|
155
|
+
raise commands.CommandRegistrationError(alias, alias_conflict=True)
|
|
156
|
+
|
|
157
|
+
self.all_commands[alias] = command
|
|
158
|
+
|
|
159
|
+
def _remove_root_aliases(self, command: commands.Command) -> None:
|
|
160
|
+
"""Recursively remove root aliases for ``command`` and any of its subcommands."""
|
|
161
|
+
if isinstance(command, commands.Group):
|
|
162
|
+
for subcommand in command.commands:
|
|
163
|
+
self._remove_root_aliases(subcommand)
|
|
164
|
+
|
|
165
|
+
for alias in getattr(command, "root_aliases", ()):
|
|
166
|
+
self.all_commands.pop(alias, None)
|
|
167
|
+
|
|
168
|
+
async def add_cog(self, cog: commands.Cog) -> None:
|
|
169
|
+
"""Add the given ``cog`` to the bot and log the operation."""
|
|
170
|
+
await super().add_cog(cog)
|
|
171
|
+
log.info(f"Cog loaded: {cog.qualified_name}")
|
|
172
|
+
|
|
173
|
+
def add_command(self, command: commands.Command) -> None:
|
|
174
|
+
"""Add ``command`` as normal and then add its root aliases to the bot."""
|
|
175
|
+
super().add_command(command)
|
|
176
|
+
self._add_root_aliases(command)
|
|
177
|
+
|
|
178
|
+
def remove_command(self, name: str) -> commands.Command | None:
|
|
179
|
+
"""
|
|
180
|
+
Remove a command/alias as normal and then remove its root aliases from the bot.
|
|
181
|
+
|
|
182
|
+
Individual root aliases cannot be removed by this function.
|
|
183
|
+
To remove them, either remove the entire command or manually edit `bot.all_commands`.
|
|
184
|
+
"""
|
|
185
|
+
command = super().remove_command(name)
|
|
186
|
+
if command is None:
|
|
187
|
+
# Even if it's a root alias, there's no way to get the Bot instance to remove the alias.
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
self._remove_root_aliases(command)
|
|
191
|
+
return command
|
|
192
|
+
|
|
193
|
+
def clear(self) -> None:
|
|
194
|
+
"""Not implemented! Re-instantiate the bot instead of attempting to re-use a closed one."""
|
|
195
|
+
raise NotImplementedError("Re-using a Bot object after closing it is not supported.")
|
|
196
|
+
|
|
197
|
+
async def on_guild_unavailable(self, guild: discord.Guild) -> None:
|
|
198
|
+
"""Clear the internal guild available event when self.guild_id becomes unavailable."""
|
|
199
|
+
if guild.id != self.guild_id:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
self._guild_available.clear()
|
|
203
|
+
|
|
204
|
+
async def on_guild_available(self, guild: discord.Guild) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Set the internal guild available event when self.guild_id becomes available.
|
|
207
|
+
|
|
208
|
+
If the cache appears to still be empty (no members, no channels, or no roles), the event
|
|
209
|
+
will not be set and `guild_available_but_cache_empty` event will be emitted.
|
|
210
|
+
"""
|
|
211
|
+
if guild.id != self.guild_id:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
if not guild.roles or not guild.members or not guild.channels:
|
|
215
|
+
msg = "Guild available event was dispatched but the cache appears to still be empty!"
|
|
216
|
+
await self.log_to_dev_log(msg)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
self._guild_available.set()
|
|
220
|
+
|
|
221
|
+
async def log_to_dev_log(self, message: str) -> None:
|
|
222
|
+
"""Log the given message to #dev-log."""
|
|
223
|
+
...
|
|
224
|
+
|
|
225
|
+
async def wait_until_guild_available(self) -> None:
|
|
226
|
+
"""
|
|
227
|
+
Wait until the guild that matches the ``guild_id`` given at init is available (and the cache is ready).
|
|
228
|
+
|
|
229
|
+
The on_ready event is inadequate because it only waits 2 seconds for a GUILD_CREATE
|
|
230
|
+
gateway event before giving up and thus not populating the cache for unavailable guilds.
|
|
231
|
+
"""
|
|
232
|
+
await self._guild_available.wait()
|
|
233
|
+
|
|
234
|
+
async def process_commands(self, message: discord.Message) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Overwrite default Discord.py behaviour to process commands only after ensuring extensions are loaded.
|
|
237
|
+
|
|
238
|
+
This extension check is only relevant for clients that make use of :obj:`pydis_core.BotBase.load_extensions`.
|
|
239
|
+
"""
|
|
240
|
+
if self._extension_loading_task:
|
|
241
|
+
await self._extension_loading_task
|
|
242
|
+
await super().process_commands(message)
|
|
243
|
+
|
|
244
|
+
async def setup_hook(self) -> None:
|
|
245
|
+
"""
|
|
246
|
+
An async init to startup generic services.
|
|
247
|
+
|
|
248
|
+
Connects to statsd, and calls
|
|
249
|
+
:func:`AsyncStatsClient.create_socket <pydis_core.async_stats.AsyncStatsClient.create_socket>`
|
|
250
|
+
and :func:`ping_services`.
|
|
251
|
+
"""
|
|
252
|
+
loop = asyncio.get_running_loop()
|
|
253
|
+
|
|
254
|
+
self._guild_available = asyncio.Event()
|
|
255
|
+
|
|
256
|
+
self._resolver = aiohttp.AsyncResolver()
|
|
257
|
+
self._connector = aiohttp.TCPConnector(
|
|
258
|
+
resolver=self._resolver,
|
|
259
|
+
family=socket.AF_INET,
|
|
260
|
+
)
|
|
261
|
+
self.http.connector = self._connector
|
|
262
|
+
|
|
263
|
+
if getattr(self, "redis_session", False) and not self.redis_session.valid:
|
|
264
|
+
# If the RedisSession was somehow closed, we try to reconnect it
|
|
265
|
+
# here. Normally, this shouldn't happen.
|
|
266
|
+
await self.redis_session.connect(ping=True)
|
|
267
|
+
|
|
268
|
+
# Create dummy stats client first, in case `statsd_url` is unreachable or None
|
|
269
|
+
self.stats = AsyncStatsClient(loop, "127.0.0.1")
|
|
270
|
+
if self.statsd_url:
|
|
271
|
+
self._connect_statsd(self.statsd_url, loop)
|
|
272
|
+
|
|
273
|
+
await self.stats.create_socket()
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
await self.ping_services()
|
|
277
|
+
except Exception as e:
|
|
278
|
+
raise StartupError(e)
|
|
279
|
+
|
|
280
|
+
async def ping_services(self) -> None:
|
|
281
|
+
"""Ping all required services on setup to ensure they are up before starting."""
|
|
282
|
+
...
|
|
283
|
+
|
|
284
|
+
async def close(self) -> None:
|
|
285
|
+
"""Close the Discord connection, and the aiohttp session, connector, statsd client, and resolver."""
|
|
286
|
+
# Done before super().close() to allow tasks finish before the HTTP session closes.
|
|
287
|
+
for ext in list(self.extensions):
|
|
288
|
+
with suppress(Exception):
|
|
289
|
+
await self.unload_extension(ext)
|
|
290
|
+
|
|
291
|
+
for cog in list(self.cogs):
|
|
292
|
+
with suppress(Exception):
|
|
293
|
+
await self.remove_cog(cog)
|
|
294
|
+
|
|
295
|
+
# Now actually do full close of bot
|
|
296
|
+
await super().close()
|
|
297
|
+
|
|
298
|
+
if self.api_client:
|
|
299
|
+
await self.api_client.close()
|
|
300
|
+
|
|
301
|
+
if self.http_session:
|
|
302
|
+
await self.http_session.close()
|
|
303
|
+
|
|
304
|
+
if self._connector:
|
|
305
|
+
await self._connector.close()
|
|
306
|
+
|
|
307
|
+
if self._resolver:
|
|
308
|
+
await self._resolver.close()
|
|
309
|
+
|
|
310
|
+
if getattr(self.stats, "_transport", False):
|
|
311
|
+
self.stats._transport.close()
|
|
312
|
+
|
|
313
|
+
if self._statsd_timerhandle:
|
|
314
|
+
self._statsd_timerhandle.cancel()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""An async transport method for statsd communication."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
from statsd.client.base import StatsClientBase
|
|
7
|
+
|
|
8
|
+
from pydis_core.utils import scheduling
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncStatsClient(StatsClientBase):
|
|
12
|
+
"""An async implementation of :obj:`statsd.client.base.StatsClientBase` that supports async stat communication."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
loop: asyncio.AbstractEventLoop,
|
|
17
|
+
host: str = "localhost",
|
|
18
|
+
port: int = 8125,
|
|
19
|
+
prefix: str | None = None
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Create a new :obj:`AsyncStatsClient`.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
loop (asyncio.AbstractEventLoop): The event loop to use when creating the
|
|
26
|
+
:obj:`asyncio.loop.create_datagram_endpoint`.
|
|
27
|
+
host: The host to connect to.
|
|
28
|
+
port: The port to connect to.
|
|
29
|
+
prefix: The prefix to use for all stats.
|
|
30
|
+
"""
|
|
31
|
+
_, _, _, _, addr = socket.getaddrinfo(
|
|
32
|
+
host, port, socket.AF_INET, socket.SOCK_DGRAM
|
|
33
|
+
)[0]
|
|
34
|
+
self._addr = addr
|
|
35
|
+
self._prefix = prefix
|
|
36
|
+
self._loop = loop
|
|
37
|
+
self._transport: asyncio.DatagramTransport | None = None
|
|
38
|
+
|
|
39
|
+
async def create_socket(self) -> None:
|
|
40
|
+
"""Use :obj:`asyncio.loop.create_datagram_endpoint` from the loop given on init to create a socket."""
|
|
41
|
+
self._transport, _ = await self._loop.create_datagram_endpoint(
|
|
42
|
+
asyncio.DatagramProtocol,
|
|
43
|
+
family=socket.AF_INET,
|
|
44
|
+
remote_addr=self._addr
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _send(self, data: str) -> None:
|
|
48
|
+
"""Start an async task to send data to statsd."""
|
|
49
|
+
scheduling.create_task(self._async_send(data), event_loop=self._loop)
|
|
50
|
+
|
|
51
|
+
async def _async_send(self, data: str) -> None:
|
|
52
|
+
"""Send data to the statsd server using the async transport."""
|
|
53
|
+
self._transport.sendto(data.encode("ascii"), self._addr)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["AsyncStatsClient"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""An API wrapper around the Site API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from urllib.parse import quote as quote_url
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from pydis_core.utils.logging import get_logger
|
|
9
|
+
|
|
10
|
+
log = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ResponseCodeError(ValueError):
|
|
14
|
+
"""Raised in :meth:`APIClient.request` when a non-OK HTTP response is received."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
response: aiohttp.ClientResponse,
|
|
19
|
+
response_json: dict | None = None,
|
|
20
|
+
response_text: str | None = None
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Initialize a new :obj:`ResponseCodeError` instance.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
response (:obj:`aiohttp.ClientResponse`): The response object from the request.
|
|
27
|
+
response_json: The JSON response returned from the request, if any.
|
|
28
|
+
response_text: The text of the request, if any.
|
|
29
|
+
"""
|
|
30
|
+
self.status = response.status
|
|
31
|
+
self.response_json = response_json or {}
|
|
32
|
+
self.response_text = response_text
|
|
33
|
+
self.response = response
|
|
34
|
+
|
|
35
|
+
def __str__(self):
|
|
36
|
+
"""Return a string representation of the error."""
|
|
37
|
+
response = self.response_json or self.response_text
|
|
38
|
+
return f"Status: {self.status} Response: {response}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class APIClient:
|
|
42
|
+
"""A wrapper for the Django Site API."""
|
|
43
|
+
|
|
44
|
+
session: aiohttp.ClientSession | None = None
|
|
45
|
+
loop: asyncio.AbstractEventLoop = None
|
|
46
|
+
|
|
47
|
+
def __init__(self, site_api_url: str, site_api_token: str, **session_kwargs):
|
|
48
|
+
"""
|
|
49
|
+
Initialize a new :obj:`APIClient` instance.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
site_api_url: The URL of the site API.
|
|
53
|
+
site_api_token: The token to use for authentication.
|
|
54
|
+
session_kwargs: Keyword arguments to pass to the :obj:`aiohttp.ClientSession` constructor.
|
|
55
|
+
"""
|
|
56
|
+
self.site_api_url = site_api_url
|
|
57
|
+
|
|
58
|
+
auth_headers = {
|
|
59
|
+
"Authorization": f"Token {site_api_token}"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if "headers" in session_kwargs:
|
|
63
|
+
session_kwargs["headers"].update(auth_headers)
|
|
64
|
+
else:
|
|
65
|
+
session_kwargs["headers"] = auth_headers
|
|
66
|
+
|
|
67
|
+
# aiohttp will complain if APIClient gets instantiated outside a coroutine. Thankfully, we
|
|
68
|
+
# don't and shouldn't need to do that, so we can avoid scheduling a task to create it.
|
|
69
|
+
self.session = aiohttp.ClientSession(**session_kwargs)
|
|
70
|
+
|
|
71
|
+
def _url_for(self, endpoint: str) -> str:
|
|
72
|
+
return f"{self.site_api_url}/{quote_url(endpoint)}"
|
|
73
|
+
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
"""Close the aiohttp session."""
|
|
76
|
+
await self.session.close()
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
async def maybe_raise_for_status(response: aiohttp.ClientResponse, should_raise: bool) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Raise :exc:`ResponseCodeError` for non-OK response if an exception should be raised.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
response (:obj:`aiohttp.ClientResponse`): The response to check.
|
|
85
|
+
should_raise: Whether or not to raise an exception.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
:exc:`ResponseCodeError`:
|
|
89
|
+
If the response is not OK and ``should_raise`` is True.
|
|
90
|
+
"""
|
|
91
|
+
if should_raise and response.status >= 400:
|
|
92
|
+
try:
|
|
93
|
+
response_json = await response.json()
|
|
94
|
+
raise ResponseCodeError(response=response, response_json=response_json)
|
|
95
|
+
except aiohttp.ContentTypeError:
|
|
96
|
+
response_text = await response.text()
|
|
97
|
+
raise ResponseCodeError(response=response, response_text=response_text)
|
|
98
|
+
|
|
99
|
+
async def request(self, method: str, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
|
|
100
|
+
"""
|
|
101
|
+
Send an HTTP request to the site API and return the JSON response.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
method: The HTTP method to use.
|
|
105
|
+
endpoint: The endpoint to send the request to.
|
|
106
|
+
raise_for_status: Whether or not to raise an exception if the response is not OK.
|
|
107
|
+
**kwargs: Any extra keyword arguments to pass to :func:`aiohttp.request`.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The JSON response the API returns.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
:exc:`ResponseCodeError`:
|
|
114
|
+
If the response is not OK and ``raise_for_status`` is True.
|
|
115
|
+
"""
|
|
116
|
+
async with self.session.request(method.upper(), self._url_for(endpoint), **kwargs) as resp:
|
|
117
|
+
await self.maybe_raise_for_status(resp, raise_for_status)
|
|
118
|
+
return await resp.json()
|
|
119
|
+
|
|
120
|
+
async def get(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
|
|
121
|
+
"""Equivalent to :meth:`APIClient.request` with GET passed as the method."""
|
|
122
|
+
return await self.request("GET", endpoint, raise_for_status=raise_for_status, **kwargs)
|
|
123
|
+
|
|
124
|
+
async def patch(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
|
|
125
|
+
"""Equivalent to :meth:`APIClient.request` with PATCH passed as the method."""
|
|
126
|
+
return await self.request("PATCH", endpoint, raise_for_status=raise_for_status, **kwargs)
|
|
127
|
+
|
|
128
|
+
async def post(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
|
|
129
|
+
"""Equivalent to :meth:`APIClient.request` with POST passed as the method."""
|
|
130
|
+
return await self.request("POST", endpoint, raise_for_status=raise_for_status, **kwargs)
|
|
131
|
+
|
|
132
|
+
async def put(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict:
|
|
133
|
+
"""Equivalent to :meth:`APIClient.request` with PUT passed as the method."""
|
|
134
|
+
return await self.request("PUT", endpoint, raise_for_status=raise_for_status, **kwargs)
|
|
135
|
+
|
|
136
|
+
async def delete(self, endpoint: str, *, raise_for_status: bool = True, **kwargs) -> dict | None:
|
|
137
|
+
"""
|
|
138
|
+
Send a DELETE request to the site API and return the JSON response.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
endpoint: The endpoint to send the request to.
|
|
142
|
+
raise_for_status: Whether or not to raise an exception if the response is not OK.
|
|
143
|
+
**kwargs: Any extra keyword arguments to pass to :func:`aiohttp.request`.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The JSON response the API returns, or None if the response is 204 No Content.
|
|
147
|
+
"""
|
|
148
|
+
async with self.session.delete(self._url_for(endpoint), **kwargs) as resp:
|
|
149
|
+
if resp.status == 204:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
await self.maybe_raise_for_status(resp, raise_for_status)
|
|
153
|
+
return await resp.json()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["APIClient", "ResponseCodeError"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Useful utilities and tools for Discord bot development."""
|
|
2
|
+
|
|
3
|
+
from pydis_core.utils import (
|
|
4
|
+
_monkey_patches,
|
|
5
|
+
caching,
|
|
6
|
+
channel,
|
|
7
|
+
commands,
|
|
8
|
+
cooldown,
|
|
9
|
+
error_handling,
|
|
10
|
+
function,
|
|
11
|
+
interactions,
|
|
12
|
+
logging,
|
|
13
|
+
members,
|
|
14
|
+
paste_service,
|
|
15
|
+
regex,
|
|
16
|
+
scheduling,
|
|
17
|
+
)
|
|
18
|
+
from pydis_core.utils._extensions import unqualify
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def apply_monkey_patches() -> None:
|
|
22
|
+
"""
|
|
23
|
+
Applies all common monkey patches for our bots.
|
|
24
|
+
|
|
25
|
+
Patches :obj:`discord.ext.commands.Command` and :obj:`discord.ext.commands.Group` to support root aliases.
|
|
26
|
+
A ``root_aliases`` keyword argument is added to these two objects, which is a sequence of alias names
|
|
27
|
+
that will act as top-level groups rather than being aliases of the command's group.
|
|
28
|
+
|
|
29
|
+
It's stored as an attribute also named ``root_aliases``
|
|
30
|
+
|
|
31
|
+
Patches discord's internal ``send_typing`` method so that it ignores 403 errors from Discord.
|
|
32
|
+
When under heavy load Discord has added a CloudFlare worker to this route, which causes 403 errors to be thrown.
|
|
33
|
+
"""
|
|
34
|
+
_monkey_patches._apply_monkey_patches()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
apply_monkey_patches,
|
|
39
|
+
caching,
|
|
40
|
+
channel,
|
|
41
|
+
commands,
|
|
42
|
+
cooldown,
|
|
43
|
+
error_handling,
|
|
44
|
+
function,
|
|
45
|
+
interactions,
|
|
46
|
+
logging,
|
|
47
|
+
members,
|
|
48
|
+
paste_service,
|
|
49
|
+
regex,
|
|
50
|
+
scheduling,
|
|
51
|
+
unqualify,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
__all__ = [module.__name__ for module in __all__]
|