scurrypy 0.4__tar.gz → 0.4.2__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.

Potentially problematic release.


This version of scurrypy might be problematic. Click here for more details.

Files changed (59) hide show
  1. {scurrypy-0.4 → scurrypy-0.4.2}/PKG-INFO +1 -1
  2. {scurrypy-0.4 → scurrypy-0.4.2}/discord/client.py +9 -7
  3. {scurrypy-0.4 → scurrypy-0.4.2}/discord/dispatch/command_dispatcher.py +3 -2
  4. {scurrypy-0.4 → scurrypy-0.4.2}/discord/dispatch/event_dispatcher.py +1 -1
  5. {scurrypy-0.4 → scurrypy-0.4.2}/discord/dispatch/prefix_dispatcher.py +1 -0
  6. {scurrypy-0.4 → scurrypy-0.4.2}/discord/gateway.py +2 -5
  7. scurrypy-0.4.2/discord/http.py +217 -0
  8. {scurrypy-0.4 → scurrypy-0.4.2}/discord/logger.py +6 -0
  9. {scurrypy-0.4 → scurrypy-0.4.2}/discord/resources/channel.py +1 -1
  10. {scurrypy-0.4 → scurrypy-0.4.2}/discord/resources/guild.py +4 -3
  11. {scurrypy-0.4 → scurrypy-0.4.2}/discord/resources/interaction.py +3 -3
  12. {scurrypy-0.4 → scurrypy-0.4.2}/discord/resources/message.py +3 -3
  13. {scurrypy-0.4 → scurrypy-0.4.2}/pyproject.toml +1 -1
  14. {scurrypy-0.4 → scurrypy-0.4.2}/scurrypy.egg-info/PKG-INFO +1 -1
  15. scurrypy-0.4/discord/http.py +0 -280
  16. {scurrypy-0.4 → scurrypy-0.4.2}/LICENSE +0 -0
  17. {scurrypy-0.4 → scurrypy-0.4.2}/README.md +0 -0
  18. {scurrypy-0.4 → scurrypy-0.4.2}/discord/__init__.py +0 -0
  19. {scurrypy-0.4 → scurrypy-0.4.2}/discord/client_like.py +0 -0
  20. {scurrypy-0.4 → scurrypy-0.4.2}/discord/config.py +0 -0
  21. {scurrypy-0.4 → scurrypy-0.4.2}/discord/dispatch/__init__.py +0 -0
  22. {scurrypy-0.4 → scurrypy-0.4.2}/discord/error.py +0 -0
  23. {scurrypy-0.4 → scurrypy-0.4.2}/discord/events/__init__.py +0 -0
  24. {scurrypy-0.4 → scurrypy-0.4.2}/discord/events/channel_events.py +0 -0
  25. {scurrypy-0.4 → scurrypy-0.4.2}/discord/events/guild_events.py +0 -0
  26. {scurrypy-0.4 → scurrypy-0.4.2}/discord/events/hello_event.py +0 -0
  27. {scurrypy-0.4 → scurrypy-0.4.2}/discord/events/interaction_events.py +0 -0
  28. {scurrypy-0.4 → scurrypy-0.4.2}/discord/events/message_events.py +0 -0
  29. {scurrypy-0.4 → scurrypy-0.4.2}/discord/events/reaction_events.py +0 -0
  30. {scurrypy-0.4 → scurrypy-0.4.2}/discord/events/ready_event.py +0 -0
  31. {scurrypy-0.4 → scurrypy-0.4.2}/discord/intents.py +0 -0
  32. {scurrypy-0.4 → scurrypy-0.4.2}/discord/model.py +0 -0
  33. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/__init__.py +0 -0
  34. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/application.py +0 -0
  35. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/emoji.py +0 -0
  36. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/guild.py +0 -0
  37. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/integration.py +0 -0
  38. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/interaction.py +0 -0
  39. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/member.py +0 -0
  40. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/role.py +0 -0
  41. {scurrypy-0.4 → scurrypy-0.4.2}/discord/models/user.py +0 -0
  42. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/__init__.py +0 -0
  43. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/action_row.py +0 -0
  44. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/channel.py +0 -0
  45. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/command.py +0 -0
  46. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/component_types.py +0 -0
  47. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/components_v2.py +0 -0
  48. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/embed.py +0 -0
  49. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/message.py +0 -0
  50. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/modal.py +0 -0
  51. {scurrypy-0.4 → scurrypy-0.4.2}/discord/parts/role.py +0 -0
  52. {scurrypy-0.4 → scurrypy-0.4.2}/discord/resources/__init__.py +0 -0
  53. {scurrypy-0.4 → scurrypy-0.4.2}/discord/resources/application.py +0 -0
  54. {scurrypy-0.4 → scurrypy-0.4.2}/discord/resources/bot_emojis.py +0 -0
  55. {scurrypy-0.4 → scurrypy-0.4.2}/discord/resources/user.py +0 -0
  56. {scurrypy-0.4 → scurrypy-0.4.2}/scurrypy.egg-info/SOURCES.txt +0 -0
  57. {scurrypy-0.4 → scurrypy-0.4.2}/scurrypy.egg-info/dependency_links.txt +0 -0
  58. {scurrypy-0.4 → scurrypy-0.4.2}/scurrypy.egg-info/top_level.txt +0 -0
  59. {scurrypy-0.4 → scurrypy-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scurrypy
3
- Version: 0.4
3
+ Version: 0.4.2
4
4
  Summary: Dataclass-driven Discord API Wrapper in Python
5
5
  Author: Furmissile
6
6
  Requires-Python: >=3.10
@@ -228,9 +228,9 @@ class Client(ClientLike):
228
228
  guild_id (int): id of the target guild
229
229
  """
230
230
  if self._guild_commands.get(guild_id):
231
- self._logger.log_info(f"Guild {guild_id} already queued, skipping clear.")
231
+ self._logger.log_warn(f"Guild {guild_id} already queued, skipping clear.")
232
232
  return
233
-
233
+
234
234
  self._guild_commands[guild_id] = []
235
235
 
236
236
  async def _listen(self):
@@ -268,7 +268,7 @@ class Client(ClientLike):
268
268
  self._ws.sequence = None
269
269
  raise ConnectionError("Invalid session.")
270
270
  case 11:
271
- self._logger.log_debug("Heartbeat ACK received")
271
+ self._logger.log_info("Heartbeat ACK received")
272
272
 
273
273
  except asyncio.CancelledError:
274
274
  break
@@ -283,14 +283,15 @@ class Client(ClientLike):
283
283
  raise
284
284
  except Exception as e:
285
285
  self._logger.log_error(f"{type(e).__name__} - {e}")
286
+ self._logger.log_traceback()
286
287
  continue
287
288
 
288
- async def start(self):
289
+ async def _start(self):
289
290
  """Runs the main lifecycle of the bot.
290
291
  Handles connection setup, heartbeat management, event loop, and automatic reconnects.
291
292
  """
292
293
  try:
293
- await self._http.start_session()
294
+ await self._http.start()
294
295
  await self._ws.connect()
295
296
  await self._ws.start_heartbeat()
296
297
 
@@ -350,7 +351,7 @@ class Client(ClientLike):
350
351
 
351
352
  # Close HTTP before gateway since it's more important
352
353
  self._logger.log_debug("Closing HTTP session...")
353
- await self._http.close_session()
354
+ await self._http.close()
354
355
 
355
356
  # Then try websocket with short timeout
356
357
  try:
@@ -365,11 +366,12 @@ class Client(ClientLike):
365
366
  setting up emojis and hooks, and then listens for gateway events.
366
367
  """
367
368
  try:
368
- asyncio.run(self.start())
369
+ asyncio.run(self._start())
369
370
  except KeyboardInterrupt:
370
371
  self._logger.log_debug("Shutdown requested via KeyboardInterrupt.")
371
372
  except Exception as e:
372
373
  self._logger.log_error(f"{type(e).__name__} {e}")
374
+ self._logger.log_traceback()
373
375
  finally:
374
376
  self._logger.log_high_priority("Bot shutting down.")
375
377
  self._logger.close()
@@ -64,7 +64,7 @@ class CommandDispatcher:
64
64
  await self._http.request(
65
65
  'PUT',
66
66
  f"applications/{self.application_id}/guilds/{guild_id}/commands",
67
- [command._to_dict() for command in cmds]
67
+ data=[command._to_dict() for command in cmds]
68
68
  )
69
69
 
70
70
  async def _register_global_commands(self, commands: list):
@@ -76,7 +76,7 @@ class CommandDispatcher:
76
76
 
77
77
  global_commands = [command._to_dict() for command in commands]
78
78
 
79
- await self._http.request('PUT', f"applications/{self.application_id}/commands", global_commands)
79
+ await self._http.request('PUT', f"applications/{self.application_id}/commands", data=global_commands)
80
80
 
81
81
  def command(self, name: str, handler):
82
82
  """Decorator to register slash commands.
@@ -161,3 +161,4 @@ class CommandDispatcher:
161
161
  self._logger.log_info(f"Interaction Event '{name}' Acknowledged.")
162
162
  except Exception as e:
163
163
  self._logger.log_error(f"Error in interaction '{name}': {e}")
164
+ self._logger.log_traceback()
@@ -43,7 +43,7 @@ class EventDispatcher:
43
43
  """HTTP session for requests."""
44
44
 
45
45
  self._logger = client._logger
46
- """HTTP session for requests"""
46
+ """Logger instance to log events."""
47
47
 
48
48
  self.config = client.config
49
49
  """User-defined bot config for persistent data."""
@@ -57,3 +57,4 @@ class PrefixDispatcher:
57
57
  self._logger.log_info(f"Prefix Event '{command}' Acknowledged.")
58
58
  except Exception as e:
59
59
  self._logger.log_error(f"Error in prefix command '{command}': {e}")
60
+ self._logger.log_traceback()
@@ -67,7 +67,6 @@ class GatewayClient:
67
67
 
68
68
  if message:
69
69
  data: dict = json.loads(message)
70
- self._logger.log_debug(f"Received: {DISCORD_OP_CODES.get(data.get('op'))} - {json.dumps(data, indent=4)}")
71
70
  self._logger.log_info(f"Received: {DISCORD_OP_CODES.get(data.get('op'))}")
72
71
  return data
73
72
 
@@ -79,7 +78,6 @@ class GatewayClient:
79
78
  Args:
80
79
  message (dict): the message to send
81
80
  """
82
- self._logger.log_debug(f"Sending payload: {message}")
83
81
  await self.ws.send(json.dumps(message))
84
82
 
85
83
  async def send_heartbeat_loop(self):
@@ -90,8 +88,7 @@ class GatewayClient:
90
88
  await asyncio.sleep(self.heartbeat_interval / 1000)
91
89
  hb_data = {"op": 1, "d": self.sequence}
92
90
  await self.send(hb_data)
93
- self._logger.log_debug(f"Sending: {hb_data}")
94
- self._logger.log_info("Heartbeat sent.")
91
+ self._logger.log_debug(f"Sent HEARTBEAT: {hb_data}")
95
92
 
96
93
  async def identify(self):
97
94
  """Sends the IDENIFY payload (token, intents, connection properties).
@@ -111,7 +108,7 @@ class GatewayClient:
111
108
  }
112
109
  await self.send(i)
113
110
  log_i = self._logger.redact(i)
114
- self._logger.log_debug(f"Sending: {log_i}")
111
+ self._logger.log_debug(f"Sent IDENTIFY: {log_i}")
115
112
  self._logger.log_high_priority("Identify sent.")
116
113
 
117
114
  async def start_heartbeat(self):
@@ -0,0 +1,217 @@
1
+ import aiohttp
2
+ import aiofiles
3
+ import asyncio
4
+ import json
5
+
6
+ from typing import Any, Optional
7
+
8
+ from .logger import Logger
9
+ from .error import DiscordError
10
+
11
+ class HTTPException(Exception):
12
+ """Represents an HTTP error response from Discord."""
13
+ def __init__(self, response: aiohttp.ClientResponse, message: str):
14
+ self.response = response
15
+ self.status = response.status
16
+ self.text = message
17
+ super().__init__(f"{response.status}: {message}")
18
+
19
+ class HTTPClient:
20
+ BASE = "https://discord.com/api/v10"
21
+ MAX_RETRIES = 3
22
+
23
+ def __init__(self, token: str, logger: Logger):
24
+ self.token = token
25
+ self.session: Optional[aiohttp.ClientSession] = None
26
+ self.logger = logger
27
+ self.global_reset = 0.0
28
+ self.global_lock = asyncio.Lock()
29
+ self.endpoint_to_bucket: dict[str, str] = {}
30
+ self.queues: dict[str, asyncio.Queue] = {}
31
+ self.workers: dict[str, asyncio.Task] = {}
32
+
33
+ async def start(self):
34
+ """Start the HTTP session."""
35
+ if not self.session:
36
+ self.session = aiohttp.ClientSession(
37
+ headers={"Authorization": f"Bot {self.token}"}
38
+ )
39
+
40
+ async def close(self):
41
+ """Close the HTTP session."""
42
+ for task in self.workers.values():
43
+ task.cancel()
44
+ if self.session and not self.session.closed:
45
+ await self.session.close()
46
+
47
+ async def request(
48
+ self,
49
+ method: str,
50
+ endpoint: str,
51
+ *,
52
+ data: Any | None = None,
53
+ params: dict | None = None,
54
+ files: Any | None = None,
55
+ ):
56
+ """Enqueues request WRT rate-limit buckets.
57
+
58
+ Args:
59
+ method (str): HTTP method (e.g., POST, GET, DELETE, PATCH, etc.)
60
+ endpoint (str): Discord endpoint (e.g., /channels/123/messages)
61
+ data (dict, optional): relevant data
62
+ params (dict, optional): relevant query params
63
+ files (list[str], optional): relevant files
64
+
65
+ Returns:
66
+ (Future): future with response
67
+ """
68
+ if not self.session:
69
+ await self.start()
70
+
71
+ bucket = self.endpoint_to_bucket.get(endpoint, endpoint)
72
+ queue = self.queues.setdefault(bucket, asyncio.Queue())
73
+ future = asyncio.get_event_loop().create_future()
74
+
75
+ await queue.put((method, endpoint, data, params, files, future))
76
+ if bucket not in self.workers:
77
+ self.workers[bucket] = asyncio.create_task(self._worker(bucket))
78
+
79
+ return await future
80
+
81
+ async def _worker(self, bucket: str):
82
+ """Processes request from specific rate-limit bucket."""
83
+
84
+ q = self.queues[bucket]
85
+ while self.session:
86
+ method, endpoint, data, params, files, future = await q.get()
87
+ try:
88
+ result = await self._send(method, endpoint, data, params, files)
89
+ if not future.done():
90
+ future.set_result(result)
91
+ except Exception as e:
92
+ if not future.done():
93
+ future.set_exception(e)
94
+ finally:
95
+ q.task_done()
96
+
97
+ async def _send(
98
+ self,
99
+ method: str,
100
+ endpoint: str,
101
+ data: Any | None,
102
+ params: dict | None,
103
+ files: Any | None,
104
+ ):
105
+ """Core HTTP request executor.
106
+
107
+ Sends a request to Discord, handling JSON payloads, files, query parameters,
108
+ rate limits, and retries.
109
+
110
+ Args:
111
+ method (str): HTTP method (e.g., 'POST', 'GET', 'DELETE', 'PATCH').
112
+ endpoint (str): Discord API endpoint (e.g., '/channels/123/messages').
113
+ data (dict | None, optional): JSON payload to include in the request body.
114
+ params (dict | None, optional): Query parameters to append to the URL.
115
+ files (list[str] | None, optional): Files to send with the request.
116
+
117
+ Raises:
118
+ (HTTPException): If the request fails after the maximum number of retries
119
+ or receives an error response.
120
+
121
+ Returns:
122
+ (dict | str | None): Parsed JSON response if available, raw text if the
123
+ response is not JSON, or None for HTTP 204 responses.
124
+ """
125
+
126
+ url = f"{self.BASE.rstrip('/')}/{endpoint.lstrip('/')}"
127
+
128
+ def sanitize_query_params(params: dict | None) -> dict | None:
129
+ if not params:
130
+ return None
131
+ return {k: ('true' if v is True else 'false' if v is False else v)
132
+ for k, v in params.items() if v is not None}
133
+
134
+ for attempt in range(self.MAX_RETRIES):
135
+ await self._check_global_limit()
136
+
137
+ kwargs = {}
138
+
139
+ if files and any(files):
140
+ payload, headers = await self._make_payload(data, files)
141
+ kwargs = {"data": payload, "headers": headers}
142
+ else:
143
+ kwargs = {"json": data}
144
+
145
+ try:
146
+ async with self.session.request(
147
+ method, url, params=sanitize_query_params(params), timeout=15, **kwargs
148
+ ) as resp:
149
+ if resp.status == 429:
150
+ data = await resp.json()
151
+ retry = float(data.get("retry_after", 1))
152
+ if data.get("global"):
153
+ self.global_reset = asyncio.get_event_loop().time() + retry
154
+ self.logger.log_warn(
155
+ f"Rate limited {retry}s ({'global' if data.get('global') else 'bucket'})"
156
+ )
157
+ await asyncio.sleep(retry + 0.5)
158
+ continue
159
+
160
+ if 200 <= resp.status < 300:
161
+ if resp.status == 204:
162
+ return None
163
+ try:
164
+ return await resp.json()
165
+ except aiohttp.ContentTypeError:
166
+ return await resp.text()
167
+
168
+ if resp.status == 400:
169
+ raise DiscordError(resp.status, await resp.json())
170
+
171
+ text = await resp.text()
172
+ raise HTTPException(resp, text)
173
+
174
+ except asyncio.TimeoutError:
175
+ self.logger.log_warn(f"Timeout on {method} {endpoint}, retrying...")
176
+ continue
177
+
178
+ raise HTTPException(resp, f"Failed after {self.MAX_RETRIES} retries")
179
+
180
+ async def _check_global_limit(self):
181
+ """Waits if the global rate-limit is in effect."""
182
+
183
+ now = asyncio.get_event_loop().time()
184
+ if now < self.global_reset:
185
+ delay = self.global_reset - now
186
+ self.logger.log_warn(f"Global rate limit active, sleeping {delay:.2f}s")
187
+ await asyncio.sleep(delay)
188
+
189
+ async def _make_payload(self, data: dict, files: list):
190
+ """Return (data, headers) for aiohttp request — supports multipart.
191
+
192
+ Args:
193
+ data (dict): request data
194
+ files (list): relevant files
195
+
196
+ Returns:
197
+ (tuple[aiohttp.FormData, dict]): form data and headers
198
+ """
199
+ headers = {}
200
+ if not files:
201
+ return data, headers
202
+
203
+ form = aiohttp.FormData()
204
+ if data:
205
+ form.add_field("payload_json", json.dumps(data))
206
+
207
+ for idx, file_path in enumerate(files):
208
+ async with aiofiles.open(file_path, 'rb') as f:
209
+ data = await f.read()
210
+ form.add_field(
211
+ f'files[{idx}]',
212
+ data,
213
+ filename=file_path.split('/')[-1],
214
+ content_type='application/octet-stream'
215
+ )
216
+
217
+ return form, headers
@@ -35,6 +35,7 @@ class Logger:
35
35
  """Log file for writing."""
36
36
  except Exception as e:
37
37
  self.log_error(f"Error {type(e)}: {e}")
38
+ self.log_traceback()
38
39
 
39
40
  self.dev_mode = dev_mode
40
41
  """If debug logs should be printed."""
@@ -52,6 +53,11 @@ class Logger:
52
53
 
53
54
  return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
54
55
 
56
+ def log_traceback(self):
57
+ if self.dev_mode == True:
58
+ import traceback
59
+ self._log("DEBUG", self.DEBUG, traceback.format_exc())
60
+
55
61
  def _log(self, level: str, color: str, message: str):
56
62
  """Internal helper that writes formatted log to both file and console.
57
63
 
@@ -148,7 +148,7 @@ class Channel(DataModel):
148
148
  if isinstance(message, str):
149
149
  message = MessageBuilder(content=message)
150
150
 
151
- data = await self._http.request("POST", f"/channels/{self.id}/messages", message._to_dict())
151
+ data = await self._http.request("POST", f"/channels/{self.id}/messages", data=message._to_dict())
152
152
 
153
153
  return Message.from_dict(data, self._http)
154
154
 
@@ -1,5 +1,6 @@
1
1
  from dataclasses import dataclass
2
2
  from typing import Optional, TypedDict, Unpack
3
+ from urllib.parse import urlencode
3
4
 
4
5
  from ..http import HTTPClient
5
6
  from ..model import DataModel
@@ -136,7 +137,7 @@ class Guild(DataModel):
136
137
  Returns:
137
138
  (Channel): the created channel
138
139
  """
139
- data = await self._http.request('POST', f'/guilds/{self.id}/channels', channel._to_dict())
140
+ data = await self._http.request('POST', f'/guilds/{self.id}/channels', data=channel._to_dict())
140
141
 
141
142
  return Channel.from_dict(data, self._http)
142
143
 
@@ -233,7 +234,7 @@ class Guild(DataModel):
233
234
  Returns:
234
235
  (RoleModel): new role data
235
236
  """
236
- data = await self._http.request('POST', f'/guilds/{self.id}/roles', role._to_dict())
237
+ data = await self._http.request('POST', f'/guilds/{self.id}/roles', data=role._to_dict())
237
238
 
238
239
  return RoleModel.from_dict(data)
239
240
 
@@ -249,7 +250,7 @@ class Guild(DataModel):
249
250
  Returns:
250
251
  (RoleModel): role with changes
251
252
  """
252
- data = await self._http.request('PATCH', f'/guilds/{self.id}/roles/{role_id}', role._to_dict())
253
+ data = await self._http.request('PATCH', f'/guilds/{self.id}/roles/{role_id}', data=role._to_dict())
253
254
 
254
255
  return RoleModel.from_dict(data)
255
256
 
@@ -122,7 +122,7 @@ class Interaction(DataModel):
122
122
  data = await self._http.request(
123
123
  'POST',
124
124
  f'/interactions/{self.id}/{self.token}/callback',
125
- content,
125
+ data=content,
126
126
  files=[fp.path for fp in message.attachments],
127
127
  params=params)
128
128
 
@@ -151,7 +151,7 @@ class Interaction(DataModel):
151
151
  await self._http.request(
152
152
  'POST',
153
153
  f'/interactions/{self.id}/{self.token}/callback',
154
- content,
154
+ data=content,
155
155
  files=[fp.path for fp in message.attachments])
156
156
 
157
157
  async def respond_modal(self, modal: ModalBuilder):
@@ -174,4 +174,4 @@ class Interaction(DataModel):
174
174
  await self._http.request(
175
175
  'POST',
176
176
  f'/interactions/{self.id}/{self.token}/callback',
177
- content)
177
+ data=content)
@@ -68,7 +68,7 @@ class Message(DataModel):
68
68
  data = await self._http.request(
69
69
  "POST",
70
70
  f"/channels/{self.channel_id}/messages",
71
- message._to_dict(),
71
+ data=message._to_dict(),
72
72
  files=[fp.path for fp in message.attachments] if message.attachments else None
73
73
  )
74
74
  return Message.from_dict(data, self._http)
@@ -88,7 +88,7 @@ class Message(DataModel):
88
88
  data = await self._http.request(
89
89
  "PATCH",
90
90
  f"/channels/{self.channel_id}/messages/{self.id}",
91
- message._to_dict(),
91
+ data=message._to_dict(),
92
92
  files=[fp.path for fp in message.attachments] if message.attachments else None)
93
93
 
94
94
  self._update(data)
@@ -110,7 +110,7 @@ class Message(DataModel):
110
110
  await self._http.request(
111
111
  'POST',
112
112
  f"/channels/{self.channel_id}/messages",
113
- message._to_dict(),
113
+ data=message._to_dict(),
114
114
  files=[fp.path for fp in message.attachments] if message.attachments else None)
115
115
 
116
116
  async def crosspost(self):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scurrypy"
7
- version = "0.4"
7
+ version = "0.4.2"
8
8
 
9
9
  description = "Dataclass-driven Discord API Wrapper in Python"
10
10
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scurrypy
3
- Version: 0.4
3
+ Version: 0.4.2
4
4
  Summary: Dataclass-driven Discord API Wrapper in Python
5
5
  Author: Furmissile
6
6
  Requires-Python: >=3.10
@@ -1,280 +0,0 @@
1
- import aiohttp
2
- import aiofiles
3
- import asyncio
4
- import time
5
- import json
6
- import ssl
7
- from dataclasses import dataclass
8
- from urllib.parse import urlencode
9
-
10
- from .logger import Logger
11
- from .error import DiscordError
12
-
13
- ssl_ctx = ssl.create_default_context()
14
- ssl_ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 # Disable old SSL
15
-
16
- DISCORD_HTTP_CODES = {
17
- 200: "Successful Request",
18
- 201: "Successful Creation",
19
- 204: "No Content",
20
- 304: "Not Modified",
21
- 400: "Bad Request",
22
- 401: "Missing Authorization",
23
- 403: "Missing Permission",
24
- 404: "Resource Not Found",
25
- 405: "Invalid Method",
26
- 429: "Rate Limited",
27
- 502: "Gateway Unavailable"
28
- }
29
-
30
- @dataclass
31
- class RequestItem:
32
- """Data container representing an HTTP request to Discord's API.
33
- Used internally by HTTPClient for queuing and processing requests.
34
- """
35
- method: str
36
- """HTTP method (e.g., GET, POST, DELETE, PUT, PATCH)"""
37
-
38
- url: str
39
- """Fully qualifying URL for this request."""
40
-
41
- endpoint: str
42
- """Endpoint of the URL for this request."""
43
-
44
- data: dict | None
45
- """Relevant data for this request."""
46
-
47
- files: list | None
48
- """Relevant files for this request."""
49
-
50
- future: asyncio.Future
51
- """Track the result of this request."""
52
-
53
- class RouteQueue:
54
- """Represents a queue of requests for a single rate-limit bucket.
55
- Manages task worker that processes requests for that bucket.
56
- """
57
- def __init__(self):
58
- self.queue = asyncio.Queue()
59
- """Queue holding RequestItem for this bucket."""
60
-
61
- self.worker = None
62
- """Process for executing request for this bucket."""
63
-
64
- class HTTPClient:
65
- """Handles all HTTP communication with Discord's API
66
- including rate-limiting by bucket and globally, async request handling,
67
- multipart/form-data file uploads, and error handling/retries.
68
- """
69
- def __init__(self, token: str, logger: Logger):
70
- self.token = token
71
- """The bot's token."""
72
-
73
- self._logger = logger
74
- """Logger instance to log events."""
75
-
76
- self.session: aiohttp.ClientSession = None
77
- """Client session instance."""
78
-
79
- self.global_reset = 0
80
- """Global rete limit cooldown if a global rate limit is active."""
81
-
82
- self.global_lock: asyncio.Lock = None
83
- """Lock on queues to avoid race conditions."""
84
-
85
- self.pending_queue: asyncio.Queue = None
86
- """Queue for requests not yet assigned to bucket."""
87
-
88
- self.pending_worker: asyncio.Task = None
89
- """Task processing for the pending queue."""
90
-
91
- self.endpoint_to_bucket: dict[str, str] = {}
92
- """Maps endpoints to rate-limit buckets"""
93
-
94
- self.bucket_queues: dict[str, RouteQueue] = {}
95
- """Maps endpoints to RouteQueue objects."""
96
-
97
- self._sentinel = object()
98
- """Sentinel to terminate session."""
99
-
100
- self.base_url = "https://discord.com/api/v10"
101
- """Base URL for discord's API requests."""
102
-
103
- async def start_session(self):
104
- """Initializes aiohttp session, queues, locks, and starting pending worker."""
105
- self.session = aiohttp.ClientSession()
106
- self.pending_queue = asyncio.Queue()
107
- self.global_lock = asyncio.Lock()
108
- self.pending_worker = asyncio.create_task(self._pending_worker())
109
- self._logger.log_debug("Session started.")
110
-
111
- async def request(self, method: str, endpoint: str, data=None, params=None, files=None):
112
- """Enqueues request WRT rate-limit buckets.
113
-
114
- Args:
115
- method (str): HTTP method (e.g., POST, GET, DELETE, PATCH, etc.)
116
- endpoint (str): Discord endpoint (e.g., /channels/123/messages)
117
- data (dict, optional): relevant data
118
- files (list[str], optional): relevant files
119
-
120
- Returns:
121
- (Future): future with response
122
- """
123
- url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" # normalize for single slashes
124
- if params:
125
- url += f"?{urlencode(params)}"
126
-
127
- future = asyncio.get_event_loop().create_future()
128
-
129
- if endpoint in self.endpoint_to_bucket:
130
- bucket = self.endpoint_to_bucket[endpoint]
131
- if bucket not in self.bucket_queues:
132
- self.bucket_queues[bucket] = RouteQueue()
133
- self.bucket_queues[bucket].worker = asyncio.create_task(
134
- self._route_worker(bucket)
135
- )
136
- await self.bucket_queues[bucket].queue.put(RequestItem(method, url, endpoint, data, files, future))
137
- else:
138
- await self.pending_queue.put(RequestItem(method, url, endpoint, data, files, future))
139
-
140
- return await future
141
-
142
- async def _pending_worker(self):
143
- """Processes requests from global pending queue."""
144
- while True:
145
- item = await self.pending_queue.get()
146
- if item is self._sentinel:
147
- self.pending_queue.task_done()
148
- break
149
- await self._process_request(item)
150
- self.pending_queue.task_done()
151
-
152
- async def _route_worker(self, bucket: str):
153
- """Processes request from specific rate-limit bucket.
154
-
155
- Args:
156
- bucket (str): endpoint
157
- """
158
- queue = self.bucket_queues[bucket].queue
159
- while True:
160
- item = await queue.get()
161
- if item is self._sentinel:
162
- queue.task_done()
163
- break
164
- await self._process_request(item)
165
- queue.task_done()
166
-
167
- async def _process_request(self, item: RequestItem):
168
- """Core request execution. Handles headers, payload, files, retries, and bucket assignment.
169
-
170
- Args:
171
- item (RequestItem): incoming request
172
-
173
- Raises:
174
- DiscordError: discord error object
175
- """
176
- try:
177
- await self._check_global_limit()
178
-
179
- headers = {"Authorization": f"Bot {self.token}"}
180
-
181
- # Build multipart if files exist
182
- request_kwargs = {'headers': headers, 'ssl': ssl_ctx}
183
-
184
- if item.files: # only create FormData if files exist
185
- form = aiohttp.FormData()
186
- form.add_field('payload_json', json.dumps(item.data))
187
-
188
- for idx, file_path in enumerate(item.files):
189
- try:
190
- async with aiofiles.open(file_path, 'rb') as f:
191
- data = await f.read()
192
- form.add_field(
193
- f'files[{idx}]',
194
- data,
195
- filename=file_path.split('/')[-1],
196
- content_type='application/octet-stream'
197
- )
198
- except FileNotFoundError:
199
- self._logger.log_warn(f"File '{file_path}' could not be found.")
200
- break
201
-
202
- request_kwargs['data'] = form
203
-
204
- elif item.data is not None:
205
- request_kwargs['json'] = item.data # aiohttp sets Content-Type automatically
206
-
207
- async with self.session.request(item.method, item.url, **request_kwargs) as resp:
208
- self._logger.log_debug(f"{item.method} {item.endpoint}: {resp.status} {DISCORD_HTTP_CODES.get(resp.status, 'Unknown Status')}")
209
-
210
- # if triggered rate-limit
211
- if resp.status == 429:
212
- data = await resp.json()
213
- retry_after = float(data.get("retry_after", 1))
214
- is_global = data.get("global")
215
- if is_global:
216
- self.global_reset = time.time() + retry_after
217
-
218
- try:
219
- self._logger.log_warn( f"You are being rate limited on {item.endpoint}. Retrying in {retry_after}s (global={is_global})")
220
- await asyncio.sleep(retry_after + 0.5)
221
- except asyncio.CancelledError:
222
- # shutdown is happening
223
- raise
224
-
225
- # retry the request
226
- await self._process_request(item)
227
- return
228
-
229
- # if response failed (either lethal or recoverable)
230
- elif resp.status not in [200, 201, 204]:
231
- raise DiscordError(resp.status, await resp.json())
232
-
233
- await self._handle_response(item, resp)
234
-
235
- # Handle rate-limit bucket headers
236
- bucket = resp.headers.get("X-RateLimit-Bucket")
237
- if bucket and item.endpoint not in self.endpoint_to_bucket:
238
- self.endpoint_to_bucket[item.endpoint] = bucket
239
- if bucket not in self.bucket_queues:
240
- self.bucket_queues[bucket] = RouteQueue()
241
- self.bucket_queues[bucket].worker = asyncio.create_task(
242
- self._route_worker(bucket)
243
- )
244
-
245
- except Exception as e:
246
- if not item.future.done():
247
- item.future.set_exception(e)
248
-
249
- async def _handle_response(self, item: RequestItem, resp: aiohttp.ClientResponse):
250
- """Resolves future with parsed JSON/text response.
251
-
252
- Args:
253
- item (RequestItem): request data to handle
254
- resp (aiohttp.ClientResponse): response for item
255
- """
256
- if resp.status == 204:
257
- item.future.set_result((None, 204))
258
- else:
259
- try:
260
- result = await resp.json()
261
- except aiohttp.ContentTypeError:
262
- result = await resp.text()
263
- item.future.set_result(result)
264
-
265
- async def _check_global_limit(self):
266
- """Waits if the global rate-limit is in effect."""
267
- async with self.global_lock:
268
- now = time.time()
269
- if now < self.global_reset:
270
- await asyncio.sleep(self.global_reset - now)
271
-
272
- async def close_session(self):
273
- """Gracefully shuts down all workes and closes aiohttp session."""
274
- # Stop workers
275
- for q in self.bucket_queues.values():
276
- await q.queue.put(self._sentinel)
277
- await self.pending_queue.put(self._sentinel)
278
-
279
- if self.session and not self.session.closed:
280
- await self.session.close()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes