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.
Files changed (72) hide show
  1. scurrypy/__init__.py +429 -0
  2. scurrypy/client.py +335 -0
  3. {discord → scurrypy}/client_like.py +8 -1
  4. scurrypy/dispatch/command_dispatcher.py +205 -0
  5. {discord → scurrypy}/dispatch/event_dispatcher.py +21 -21
  6. {discord → scurrypy}/dispatch/prefix_dispatcher.py +31 -12
  7. {discord → scurrypy}/error.py +6 -18
  8. {discord → scurrypy}/events/channel_events.py +2 -1
  9. scurrypy/events/gateway_events.py +31 -0
  10. {discord → scurrypy}/events/guild_events.py +2 -1
  11. {discord → scurrypy}/events/interaction_events.py +28 -13
  12. {discord → scurrypy}/events/message_events.py +8 -5
  13. {discord → scurrypy}/events/reaction_events.py +1 -2
  14. {discord → scurrypy}/events/ready_event.py +1 -3
  15. scurrypy/gateway.py +183 -0
  16. scurrypy/http.py +310 -0
  17. {discord → scurrypy}/intents.py +5 -7
  18. {discord → scurrypy}/logger.py +14 -61
  19. scurrypy/model.py +71 -0
  20. scurrypy/models.py +258 -0
  21. scurrypy/parts/channel.py +42 -0
  22. scurrypy/parts/command.py +90 -0
  23. scurrypy/parts/components.py +224 -0
  24. scurrypy/parts/components_v2.py +144 -0
  25. scurrypy/parts/embed.py +83 -0
  26. scurrypy/parts/message.py +134 -0
  27. scurrypy/parts/modal.py +16 -0
  28. {discord → scurrypy}/parts/role.py +2 -14
  29. {discord → scurrypy}/resources/application.py +1 -2
  30. {discord → scurrypy}/resources/bot_emojis.py +1 -1
  31. {discord → scurrypy}/resources/channel.py +9 -8
  32. {discord → scurrypy}/resources/guild.py +14 -16
  33. {discord → scurrypy}/resources/interaction.py +50 -43
  34. {discord → scurrypy}/resources/message.py +15 -16
  35. {discord → scurrypy}/resources/user.py +3 -4
  36. scurrypy-0.6.6.dist-info/METADATA +108 -0
  37. scurrypy-0.6.6.dist-info/RECORD +47 -0
  38. {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/licenses/LICENSE +1 -1
  39. scurrypy-0.6.6.dist-info/top_level.txt +1 -0
  40. discord/__init__.py +0 -223
  41. discord/client.py +0 -375
  42. discord/dispatch/command_dispatcher.py +0 -163
  43. discord/gateway.py +0 -155
  44. discord/http.py +0 -280
  45. discord/model.py +0 -90
  46. discord/models/__init__.py +0 -1
  47. discord/models/application.py +0 -37
  48. discord/models/emoji.py +0 -34
  49. discord/models/guild.py +0 -35
  50. discord/models/integration.py +0 -23
  51. discord/models/interaction.py +0 -26
  52. discord/models/member.py +0 -27
  53. discord/models/role.py +0 -53
  54. discord/models/user.py +0 -15
  55. discord/parts/action_row.py +0 -208
  56. discord/parts/channel.py +0 -20
  57. discord/parts/command.py +0 -102
  58. discord/parts/components_v2.py +0 -353
  59. discord/parts/embed.py +0 -154
  60. discord/parts/message.py +0 -194
  61. discord/parts/modal.py +0 -21
  62. scurrypy-0.4.dist-info/METADATA +0 -130
  63. scurrypy-0.4.dist-info/RECORD +0 -54
  64. scurrypy-0.4.dist-info/top_level.txt +0 -1
  65. {discord → scurrypy}/config.py +0 -0
  66. {discord → scurrypy}/dispatch/__init__.py +0 -0
  67. {discord → scurrypy}/events/__init__.py +0 -0
  68. {discord → scurrypy}/events/hello_event.py +0 -0
  69. {discord → scurrypy}/parts/__init__.py +0 -0
  70. {discord → scurrypy}/parts/component_types.py +0 -0
  71. {discord → scurrypy}/resources/__init__.py +0 -0
  72. {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)
@@ -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 using [`IntentFlagParams`][discord.intents.IntentFlagParams].
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. (set respective flag to True to toggle.)
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 name, value in flags.items():
82
- if name not in _flag_map:
83
- raise ValueError(f"Invalid flag: {name}")
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
@@ -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, dev_mode: bool = False, quiet: bool = False):
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
- dev_mode (bool, optional): toggle debug messages. Defaults to False.
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.dev_mode = dev_mode
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 now(self):
46
- """Returns current timestamp
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', 'CRITICAL', 'HIGH'):
54
+ if self.quiet and level not in ('ERROR', 'WARN', 'HIGH'):
64
55
  return # suppress lower-level logs in quiet mode
65
56
 
66
- date = self.now()
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/dev_mode.
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