scurrypy 0.4__py3-none-any.whl → 0.6.6__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.
- scurrypy/__init__.py +429 -0
- scurrypy/client.py +335 -0
- {discord → scurrypy}/client_like.py +8 -1
- scurrypy/dispatch/command_dispatcher.py +205 -0
- {discord → scurrypy}/dispatch/event_dispatcher.py +21 -21
- {discord → scurrypy}/dispatch/prefix_dispatcher.py +31 -12
- {discord → scurrypy}/error.py +6 -18
- {discord → scurrypy}/events/channel_events.py +2 -1
- scurrypy/events/gateway_events.py +31 -0
- {discord → scurrypy}/events/guild_events.py +2 -1
- {discord → scurrypy}/events/interaction_events.py +28 -13
- {discord → scurrypy}/events/message_events.py +8 -5
- {discord → scurrypy}/events/reaction_events.py +1 -2
- {discord → scurrypy}/events/ready_event.py +1 -3
- scurrypy/gateway.py +183 -0
- scurrypy/http.py +310 -0
- {discord → scurrypy}/intents.py +5 -7
- {discord → scurrypy}/logger.py +14 -61
- scurrypy/model.py +71 -0
- scurrypy/models.py +258 -0
- scurrypy/parts/channel.py +42 -0
- scurrypy/parts/command.py +90 -0
- scurrypy/parts/components.py +224 -0
- scurrypy/parts/components_v2.py +144 -0
- scurrypy/parts/embed.py +83 -0
- scurrypy/parts/message.py +134 -0
- scurrypy/parts/modal.py +16 -0
- {discord → scurrypy}/parts/role.py +2 -14
- {discord → scurrypy}/resources/application.py +1 -2
- {discord → scurrypy}/resources/bot_emojis.py +1 -1
- {discord → scurrypy}/resources/channel.py +9 -8
- {discord → scurrypy}/resources/guild.py +14 -16
- {discord → scurrypy}/resources/interaction.py +50 -43
- {discord → scurrypy}/resources/message.py +15 -16
- {discord → scurrypy}/resources/user.py +3 -4
- scurrypy-0.6.6.dist-info/METADATA +108 -0
- scurrypy-0.6.6.dist-info/RECORD +47 -0
- {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/licenses/LICENSE +1 -1
- scurrypy-0.6.6.dist-info/top_level.txt +1 -0
- discord/__init__.py +0 -223
- discord/client.py +0 -375
- discord/dispatch/command_dispatcher.py +0 -163
- discord/gateway.py +0 -155
- discord/http.py +0 -280
- discord/model.py +0 -90
- discord/models/__init__.py +0 -1
- discord/models/application.py +0 -37
- discord/models/emoji.py +0 -34
- discord/models/guild.py +0 -35
- discord/models/integration.py +0 -23
- discord/models/interaction.py +0 -26
- discord/models/member.py +0 -27
- discord/models/role.py +0 -53
- discord/models/user.py +0 -15
- discord/parts/action_row.py +0 -208
- discord/parts/channel.py +0 -20
- discord/parts/command.py +0 -102
- discord/parts/components_v2.py +0 -353
- discord/parts/embed.py +0 -154
- discord/parts/message.py +0 -194
- discord/parts/modal.py +0 -21
- scurrypy-0.4.dist-info/METADATA +0 -130
- scurrypy-0.4.dist-info/RECORD +0 -54
- scurrypy-0.4.dist-info/top_level.txt +0 -1
- {discord → scurrypy}/config.py +0 -0
- {discord → scurrypy}/dispatch/__init__.py +0 -0
- {discord → scurrypy}/events/__init__.py +0 -0
- {discord → scurrypy}/events/hello_event.py +0 -0
- {discord → scurrypy}/parts/__init__.py +0 -0
- {discord → scurrypy}/parts/component_types.py +0 -0
- {discord → scurrypy}/resources/__init__.py +0 -0
- {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/WHEEL +0 -0
scurrypy/http.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
R = request
|
|
3
|
+
EP = endpoint
|
|
4
|
+
L = Lock
|
|
5
|
+
Q = Queue
|
|
6
|
+
H = header
|
|
7
|
+
B = Bucket
|
|
8
|
+
A + B = {A:B}
|
|
9
|
+
|
|
10
|
+
[R + EP]--|L|-->[Q + EP]--|send R|-->[add/update H + B]
|
|
11
|
+
1. request by endpoint
|
|
12
|
+
2. push request to queue with lock
|
|
13
|
+
3. add/update header by bucket ID with send request
|
|
14
|
+
|
|
15
|
+
* Queue by ENDPOINT/REQUEST
|
|
16
|
+
* Bucket by HEADER
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import aiohttp
|
|
21
|
+
import aiofiles
|
|
22
|
+
import json
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from .error import DiscordError
|
|
28
|
+
from .logger import Logger
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RequestItem:
|
|
32
|
+
method: str
|
|
33
|
+
endpoint: str
|
|
34
|
+
data: Any = None
|
|
35
|
+
params: dict = None
|
|
36
|
+
files: dict = None
|
|
37
|
+
future: asyncio.Future = None
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Bucket:
|
|
41
|
+
remaining: int
|
|
42
|
+
reset_after: float
|
|
43
|
+
reset_on: float
|
|
44
|
+
sleep_task: asyncio.Task = None
|
|
45
|
+
|
|
46
|
+
class HTTPClient:
|
|
47
|
+
BASE = "https://discord.com/api/v10"
|
|
48
|
+
MAX_RETRIES = 3
|
|
49
|
+
|
|
50
|
+
def __init__(self, logger: Logger):
|
|
51
|
+
self.session = None
|
|
52
|
+
self.logger = logger
|
|
53
|
+
|
|
54
|
+
# PRE-REQUEST
|
|
55
|
+
self.queues: dict[str, asyncio.Queue] = {} # maps EP -> Q
|
|
56
|
+
self.queues_lock = asyncio.Lock() # locks queues dict for editing
|
|
57
|
+
|
|
58
|
+
self.workers: dict[str, asyncio.Task] = {} # maps EP -> worker
|
|
59
|
+
|
|
60
|
+
# POST-REQUEST
|
|
61
|
+
self.buckets: dict[str, Bucket] = {} # maps B -> Bucket
|
|
62
|
+
self.bucket_lock: dict[str, asyncio.Lock] = {} # maps B to Lock
|
|
63
|
+
self.buckets_lock = asyncio.Lock() # locks buckets dict for editing
|
|
64
|
+
|
|
65
|
+
self.global_lock = asyncio.Lock()
|
|
66
|
+
self.global_reset = 0.0
|
|
67
|
+
|
|
68
|
+
async def start(self, token: str):
|
|
69
|
+
"""Start the HTTP session."""
|
|
70
|
+
|
|
71
|
+
if not self.session:
|
|
72
|
+
self.session = aiohttp.ClientSession(headers={"Authorization": f"Bot {token}"})
|
|
73
|
+
self.logger.log_info("HTTP session started.")
|
|
74
|
+
else:
|
|
75
|
+
self.logger.log_warn("HTTP session already initialized.")
|
|
76
|
+
|
|
77
|
+
async def close(self):
|
|
78
|
+
"""Gracefully stop all workers and close the HTTP session."""
|
|
79
|
+
|
|
80
|
+
if self.session: # just the session that needs to close!
|
|
81
|
+
await self.session.close()
|
|
82
|
+
self.logger.log_info("Session closed.")
|
|
83
|
+
|
|
84
|
+
async def request(
|
|
85
|
+
self,
|
|
86
|
+
method: str,
|
|
87
|
+
endpoint: str,
|
|
88
|
+
*,
|
|
89
|
+
data: Any | None = None,
|
|
90
|
+
params: dict | None = None,
|
|
91
|
+
files: Any | None = None,
|
|
92
|
+
):
|
|
93
|
+
"""Queue a request for the given endpoint.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
method (str): HTTP method (e.g., POST, GET, DELETE, PATCH, etc.)
|
|
97
|
+
endpoint (str): Discord endpoint (e.g., /channels/123/messages)
|
|
98
|
+
data (dict, optional): relevant data
|
|
99
|
+
params (dict, optional): relevant query params
|
|
100
|
+
files (list[str], optional): relevant files
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
(Future): result or promise of request
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
# ensure a queue is in place for the requested endpoint
|
|
107
|
+
async with self.queues_lock:
|
|
108
|
+
queue = self.queues.setdefault(endpoint, asyncio.Queue())
|
|
109
|
+
|
|
110
|
+
if endpoint not in self.workers:
|
|
111
|
+
self.workers[endpoint] = asyncio.create_task(self._worker(endpoint))
|
|
112
|
+
|
|
113
|
+
# set promise
|
|
114
|
+
future = asyncio.get_event_loop().create_future()
|
|
115
|
+
|
|
116
|
+
def sanitize_query_params(params: dict | None) -> dict | None:
|
|
117
|
+
"""Sanitize a request's params for session.request
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
params (dict | None): query params (if any)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
(dict | None): the session.request-friendly version of params
|
|
124
|
+
"""
|
|
125
|
+
if not params:
|
|
126
|
+
return None
|
|
127
|
+
return {k: ('true' if v is True else 'false' if v is False else v)
|
|
128
|
+
for k, v in params.items() if v is not None}
|
|
129
|
+
|
|
130
|
+
await queue.put(RequestItem(method, endpoint, data, sanitize_query_params(params), files, future))
|
|
131
|
+
|
|
132
|
+
# return promise
|
|
133
|
+
return await future
|
|
134
|
+
|
|
135
|
+
async def _worker(self, endpoint: str):
|
|
136
|
+
"""Background worker that processes requests for this endpoint.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
endpoint (str): the endpoint to receive requests
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
# fetch the queue by endpoint
|
|
143
|
+
queue = self.queues[endpoint]
|
|
144
|
+
|
|
145
|
+
while True:
|
|
146
|
+
# get the next item in the queue
|
|
147
|
+
item: RequestItem = await queue.get()
|
|
148
|
+
|
|
149
|
+
if item is None: # sentinel = time to stop
|
|
150
|
+
queue.task_done()
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
result = await self._send(item)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
item.future.set_exception(e)
|
|
157
|
+
else:
|
|
158
|
+
item.future.set_result(result)
|
|
159
|
+
finally:
|
|
160
|
+
queue.task_done()
|
|
161
|
+
|
|
162
|
+
async def _sleep_endpoint(self, endpoint: str, bucket: Bucket):
|
|
163
|
+
"""Let an endpoint sleep for the designated reset_after seconds.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
endpoint (str): endpoint to sleep
|
|
167
|
+
bucket (Bucket): endpoint's bucket info
|
|
168
|
+
"""
|
|
169
|
+
self.logger.log_warn(f"Bucket {endpoint} rate limit is active. Sleeping for {bucket.reset_after}s...")
|
|
170
|
+
await asyncio.sleep(bucket.reset_after)
|
|
171
|
+
bucket.sleep_task = None
|
|
172
|
+
self.logger.log_high_priority(f"Bucket {endpoint} reset after {bucket.reset_after}s")
|
|
173
|
+
|
|
174
|
+
async def _check_global_rate_limit(self):
|
|
175
|
+
"""Checks if the global rate limit is after now (active)."""
|
|
176
|
+
now = asyncio.get_event_loop().time()
|
|
177
|
+
if self.global_reset > now:
|
|
178
|
+
async with self.global_lock:
|
|
179
|
+
self.logger.log_warn(f"Global reset is active. Sleeping for {self.global_reset - now}s...")
|
|
180
|
+
await asyncio.sleep(self.global_reset - now)
|
|
181
|
+
self.logger.log_high_priority(f"Global has reset after {self.global_reset - now}s...")
|
|
182
|
+
|
|
183
|
+
async def _parse_response(self, resp: aiohttp.ClientResponse):
|
|
184
|
+
"""Parse the request's response for response details.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
resp (aiohttp.ClientResponse): the response object
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
DiscordError: Error object for pretty printing if an error is returned.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
(str | dict | None): request info (if any)
|
|
194
|
+
"""
|
|
195
|
+
match resp.status:
|
|
196
|
+
case 204:
|
|
197
|
+
return None
|
|
198
|
+
case 200 | 201 | 202:
|
|
199
|
+
try:
|
|
200
|
+
return await resp.json()
|
|
201
|
+
except aiohttp.ContentTypeError:
|
|
202
|
+
return await resp.text()
|
|
203
|
+
case _:
|
|
204
|
+
try:
|
|
205
|
+
body = await resp.json()
|
|
206
|
+
except aiohttp.ContentTypeError:
|
|
207
|
+
body = await resp.text()
|
|
208
|
+
raise DiscordError(resp.status, body)
|
|
209
|
+
|
|
210
|
+
async def _update_bucket_rate_limit(self, resp: aiohttp.ClientResponse, bucket_id: str, endpoint: str):
|
|
211
|
+
"""Update the bucket for this endpoint and sleep if necessary.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
resp (aiohttp.ClientResponse): the response object
|
|
215
|
+
bucket_id (str): bucket ID provided by Discord's headers
|
|
216
|
+
endpoint (str): endpoint in which request was sent
|
|
217
|
+
"""
|
|
218
|
+
# grab lock from dict of bucket locks with a lock on dict access
|
|
219
|
+
async with self.buckets_lock:
|
|
220
|
+
lock = self.bucket_lock.setdefault(bucket_id, asyncio.Lock())
|
|
221
|
+
|
|
222
|
+
# update/add the bucket with Bucket lock
|
|
223
|
+
async with lock:
|
|
224
|
+
remaining = int(resp.headers.get('x-ratelimit-remaining', 1))
|
|
225
|
+
reset_after = float(resp.headers.get('x-ratelimit-reset-after', 0))
|
|
226
|
+
reset_on = float(resp.headers.get('x-ratelimit-reset', 0))
|
|
227
|
+
|
|
228
|
+
bucket = self.buckets.get(bucket_id)
|
|
229
|
+
|
|
230
|
+
if not bucket:
|
|
231
|
+
bucket = Bucket(remaining, reset_after, reset_on)
|
|
232
|
+
self.buckets[bucket_id] = bucket
|
|
233
|
+
else:
|
|
234
|
+
bucket.remaining = remaining
|
|
235
|
+
bucket.reset_after = reset_after
|
|
236
|
+
bucket.reset_on = reset_on
|
|
237
|
+
|
|
238
|
+
if bucket.remaining == 0 and not bucket.sleep_task:
|
|
239
|
+
bucket.sleep_task = asyncio.create_task(
|
|
240
|
+
self._sleep_endpoint(endpoint, bucket)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
elif bucket.sleep_task and not bucket.sleep_task.done():
|
|
244
|
+
await bucket.sleep_task
|
|
245
|
+
|
|
246
|
+
async def _prepare_payload(self, item: RequestItem):
|
|
247
|
+
"""Prepares the payload based on `RequestItem`.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
item (RequestItem): the request object
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
(dict): kwargs to pass to session.request
|
|
254
|
+
"""
|
|
255
|
+
if item.files and any(item.files):
|
|
256
|
+
# payload = await self._make_payload(item.data, item.files)
|
|
257
|
+
form = aiohttp.FormData()
|
|
258
|
+
form.add_field("payload_json", json.dumps(item.data))
|
|
259
|
+
|
|
260
|
+
for idx, file_path in enumerate(item.files):
|
|
261
|
+
async with aiofiles.open(file_path, 'rb') as f:
|
|
262
|
+
f_data = await f.read()
|
|
263
|
+
form.add_field(
|
|
264
|
+
f'files[{idx}]',
|
|
265
|
+
f_data,
|
|
266
|
+
filename=file_path.split('/')[-1],
|
|
267
|
+
content_type='application/octet-stream'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return {"data": form}
|
|
271
|
+
|
|
272
|
+
return {"json": item.data}
|
|
273
|
+
|
|
274
|
+
async def _send(self, item: RequestItem):
|
|
275
|
+
"""Core HTTP request executor.
|
|
276
|
+
|
|
277
|
+
Sends a request to Discord, handling JSON payloads, files, query parameters,
|
|
278
|
+
and rate limits.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
item (RequestItem): request object
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
(DiscordError): If the request fails after the maximum number of retries
|
|
285
|
+
or receives an error response.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
(dict | str | None): Parsed JSON response if available, raw text if the
|
|
289
|
+
response is not JSON, or None for HTTP 204 responses.
|
|
290
|
+
"""
|
|
291
|
+
await self._check_global_rate_limit()
|
|
292
|
+
|
|
293
|
+
kwargs = await self._prepare_payload(item)
|
|
294
|
+
|
|
295
|
+
url = f"{self.BASE.rstrip('/')}/{item.endpoint.lstrip('/')}"
|
|
296
|
+
|
|
297
|
+
async with self.session.request(
|
|
298
|
+
method=item.method, url=url, params=item.params, timeout=15, **kwargs
|
|
299
|
+
) as resp:
|
|
300
|
+
|
|
301
|
+
if resp.headers.get("X-RateLimit-Global") == "true":
|
|
302
|
+
retry_after = float(resp.headers.get("Retry-After", 0))
|
|
303
|
+
self.global_reset = asyncio.get_event_loop().time() + retry_after
|
|
304
|
+
|
|
305
|
+
bucket_id = resp.headers.get('x-ratelimit-bucket')
|
|
306
|
+
|
|
307
|
+
if bucket_id:
|
|
308
|
+
await self._update_bucket_rate_limit(resp, bucket_id, item.endpoint)
|
|
309
|
+
|
|
310
|
+
return await self._parse_response(resp)
|
{discord → scurrypy}/intents.py
RENAMED
|
@@ -54,11 +54,11 @@ class IntentFlagParams(TypedDict, total=False):
|
|
|
54
54
|
message_content: bool
|
|
55
55
|
|
|
56
56
|
def set_intents(**flags: Unpack[IntentFlagParams]):
|
|
57
|
-
"""Set bot intents
|
|
57
|
+
"""Set bot intents. See [`Intents`][scurrypy.intents.Intents]
|
|
58
58
|
`Intents.DEFAULT` = (GUILDS | GUILD_MESSAGES) will also be set.
|
|
59
59
|
|
|
60
60
|
Args:
|
|
61
|
-
flags (Unpack[IntentFlagParams]): intents to set
|
|
61
|
+
**flags (Unpack[IntentFlagParams]): intents to set
|
|
62
62
|
|
|
63
63
|
Raises:
|
|
64
64
|
(ValueError): invalid flag
|
|
@@ -78,10 +78,8 @@ def set_intents(**flags: Unpack[IntentFlagParams]):
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
intents = Intents.DEFAULT
|
|
81
|
-
for
|
|
82
|
-
if
|
|
83
|
-
|
|
84
|
-
if value:
|
|
85
|
-
intents |= _flag_map[name]
|
|
81
|
+
for k, v in flags.items():
|
|
82
|
+
if v:
|
|
83
|
+
intents |= _flag_map.get(k)
|
|
86
84
|
|
|
87
85
|
return intents
|
{discord → scurrypy}/logger.py
RENAMED
|
@@ -14,20 +14,17 @@ class Logger:
|
|
|
14
14
|
ERROR = '\033[31m'
|
|
15
15
|
"""Error color: RED"""
|
|
16
16
|
|
|
17
|
-
CRITICAL = '\033[41m'
|
|
18
|
-
"""Critical color: RED HIGHLIGHT"""
|
|
19
|
-
|
|
20
17
|
TIME = '\033[90m'
|
|
21
18
|
"""Timestamp color: GRAY"""
|
|
22
19
|
|
|
23
20
|
RESET = '\033[0m'
|
|
24
21
|
"""Reset color: DEFAULT"""
|
|
25
22
|
|
|
26
|
-
def __init__(self,
|
|
23
|
+
def __init__(self, debug_mode: bool = False, quiet: bool = False):
|
|
27
24
|
"""Initializes logger. Opens log file 'bot.log' for writing.
|
|
28
25
|
|
|
29
26
|
Args:
|
|
30
|
-
|
|
27
|
+
debug_mode (bool, optional): toggle debug messages. Defaults to False.
|
|
31
28
|
quiet: (bool, optional): supress low-priority logs (INFO, DEBUG, WARN). Defaults to False.
|
|
32
29
|
"""
|
|
33
30
|
try:
|
|
@@ -36,21 +33,15 @@ class Logger:
|
|
|
36
33
|
except Exception as e:
|
|
37
34
|
self.log_error(f"Error {type(e)}: {e}")
|
|
38
35
|
|
|
39
|
-
self.
|
|
36
|
+
self.debug_mode = debug_mode
|
|
40
37
|
"""If debug logs should be printed."""
|
|
41
38
|
|
|
42
39
|
self.quiet = quiet
|
|
43
40
|
"""If only high-level logs should be printed."""
|
|
44
41
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
(str): timestamp formatted in YYYY-MM-DD HH:MM:SS
|
|
50
|
-
"""
|
|
51
|
-
from datetime import datetime
|
|
52
|
-
|
|
53
|
-
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
42
|
+
def log_traceback(self):
|
|
43
|
+
import traceback
|
|
44
|
+
self._log("DEBUG", self.DEBUG, traceback.format_exc())
|
|
54
45
|
|
|
55
46
|
def _log(self, level: str, color: str, message: str):
|
|
56
47
|
"""Internal helper that writes formatted log to both file and console.
|
|
@@ -60,23 +51,16 @@ class Logger:
|
|
|
60
51
|
color (str): color specified by Logger properties
|
|
61
52
|
message (str): descriptive message to log
|
|
62
53
|
"""
|
|
63
|
-
if self.quiet and level not in ('ERROR', '
|
|
54
|
+
if self.quiet and level not in ('ERROR', 'WARN', 'HIGH'):
|
|
64
55
|
return # suppress lower-level logs in quiet mode
|
|
65
56
|
|
|
66
|
-
|
|
57
|
+
from datetime import datetime
|
|
58
|
+
date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
59
|
+
|
|
67
60
|
self.fp.write(f"[{date}] {level}: {message}\n")
|
|
68
61
|
self.fp.flush()
|
|
69
62
|
print(f"{self.TIME}[{date}]{self.RESET} {color}{level}:{self.RESET} {message}\n")
|
|
70
63
|
|
|
71
|
-
def log_debug(self, message: str):
|
|
72
|
-
"""Logs a debug-level message.
|
|
73
|
-
|
|
74
|
-
Args:
|
|
75
|
-
message (str): descriptive message to log
|
|
76
|
-
"""
|
|
77
|
-
if self.dev_mode == True:
|
|
78
|
-
self._log("DEBUG", self.DEBUG, message)
|
|
79
|
-
|
|
80
64
|
def log_info(self, message: str):
|
|
81
65
|
"""Logs a info-level message.
|
|
82
66
|
|
|
@@ -100,48 +84,17 @@ class Logger:
|
|
|
100
84
|
message (str): descriptive message to log
|
|
101
85
|
"""
|
|
102
86
|
self._log("ERROR", self.ERROR, message)
|
|
103
|
-
|
|
104
|
-
def log_critical(self, message: str):
|
|
105
|
-
"""Logs a critical-level message.
|
|
106
|
-
|
|
107
|
-
Args:
|
|
108
|
-
message (str): descriptive message to log
|
|
109
|
-
"""
|
|
110
|
-
self._log("CRITICAL", self.CRITICAL, message)
|
|
111
87
|
|
|
88
|
+
if self.debug_mode == True:
|
|
89
|
+
self.log_traceback()
|
|
90
|
+
|
|
112
91
|
def log_high_priority(self, message: str):
|
|
113
|
-
"""Always log this, regardless of quiet/
|
|
92
|
+
"""Always log this, regardless of quiet/debug_mode.
|
|
114
93
|
|
|
115
94
|
Args:
|
|
116
95
|
message (str): descriptive message to log
|
|
117
96
|
"""
|
|
118
97
|
self._log("HIGH", self.INFO, message)
|
|
119
|
-
|
|
120
|
-
def redact(self, data: dict, replacement: str ='*** REDACTED ***'):
|
|
121
|
-
"""Recusively redact sensitive fields (token, password, authorization, api_key) from data.
|
|
122
|
-
|
|
123
|
-
Args:
|
|
124
|
-
data (_type_): JSON to sanitize
|
|
125
|
-
replacement (str, optional): what sensitive data is replaced with. Defaults to '*** REDACTED ***'.
|
|
126
|
-
|
|
127
|
-
Returns:
|
|
128
|
-
(dict): sanitized JSON
|
|
129
|
-
"""
|
|
130
|
-
keys_to_redact = ['token', 'password', 'authorization', 'api_key']
|
|
131
|
-
|
|
132
|
-
def _redact(obj):
|
|
133
|
-
if isinstance(obj, dict):
|
|
134
|
-
return {
|
|
135
|
-
k: (replacement if k.lower() in keys_to_redact else _redact(v))
|
|
136
|
-
for k, v in obj.items()
|
|
137
|
-
}
|
|
138
|
-
elif isinstance(obj, list):
|
|
139
|
-
return [_redact(item) for item in obj]
|
|
140
|
-
return obj
|
|
141
|
-
|
|
142
|
-
import copy
|
|
143
|
-
|
|
144
|
-
return _redact(copy.deepcopy(data))
|
|
145
98
|
|
|
146
99
|
def close(self):
|
|
147
100
|
"""Closes the log file."""
|
scurrypy/model.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from dataclasses import dataclass, fields, is_dataclass
|
|
2
|
+
from typing import get_args, get_origin, Union
|
|
3
|
+
|
|
4
|
+
from .http import HTTPClient
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class DataModel:
|
|
8
|
+
"""DataModel is a base class for Discord JSONs that provides hydration from raw dicts,
|
|
9
|
+
optional field defaults, and access to HTTP-bound methods.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_dict(cls, data: dict, http: 'HTTPClient' = None):
|
|
14
|
+
"""Hydrates the given data into the dataclass child.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
data (dict): JSON data
|
|
18
|
+
http (HTTPClient, optional): HTTP session for requests
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
(dataclass): hydrated dataclass
|
|
22
|
+
"""
|
|
23
|
+
def unwrap_optional(t):
|
|
24
|
+
if get_origin(t) is Union:
|
|
25
|
+
args = tuple(a for a in get_args(t) if a is not type(None))
|
|
26
|
+
return args[0] if len(args) == 1 else Union[args]
|
|
27
|
+
return t
|
|
28
|
+
|
|
29
|
+
kwargs = {}
|
|
30
|
+
for f in fields(cls):
|
|
31
|
+
v = data.get(f.name)
|
|
32
|
+
t = unwrap_optional(f.type)
|
|
33
|
+
|
|
34
|
+
if v is None:
|
|
35
|
+
kwargs[f.name] = None
|
|
36
|
+
elif is_dataclass(t):
|
|
37
|
+
kwargs[f.name] = t.from_dict(v, http)
|
|
38
|
+
elif get_origin(t) is list:
|
|
39
|
+
lt = get_args(t)[0]
|
|
40
|
+
kwargs[f.name] = [lt.from_dict(x, http) if is_dataclass(lt) else x for x in v]
|
|
41
|
+
else:
|
|
42
|
+
try:
|
|
43
|
+
kwargs[f.name] = t(v)
|
|
44
|
+
except Exception:
|
|
45
|
+
kwargs[f.name] = v # fallback to raw
|
|
46
|
+
|
|
47
|
+
inst = cls(**kwargs)
|
|
48
|
+
if http: inst._http = http
|
|
49
|
+
return inst
|
|
50
|
+
|
|
51
|
+
def to_dict(self):
|
|
52
|
+
"""Recursively turns the dataclass into a dictionary and drops empty fields.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
(dict): serialized dataclasss
|
|
56
|
+
"""
|
|
57
|
+
def serialize(val):
|
|
58
|
+
if isinstance(val, list):
|
|
59
|
+
return [serialize(v) for v in val if v is not None]
|
|
60
|
+
if isinstance(val, DataModel):
|
|
61
|
+
return val.to_dict()
|
|
62
|
+
return val
|
|
63
|
+
|
|
64
|
+
result = {}
|
|
65
|
+
for f in fields(self):
|
|
66
|
+
if f.name.startswith('_'):
|
|
67
|
+
continue
|
|
68
|
+
val = getattr(self, f.name)
|
|
69
|
+
# if val not in (None, [], {}, "", 0):
|
|
70
|
+
result[f.name] = serialize(val)
|
|
71
|
+
return result
|