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
discord/http.py DELETED
@@ -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()
discord/model.py DELETED
@@ -1,90 +0,0 @@
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
- kwargs = {}
24
-
25
- def unwrap_optional(typ):
26
- """Remove NoneType from Optional or leave Union as-is."""
27
- if get_origin(typ) is Union:
28
- args = tuple(a for a in get_args(typ) if a is not type(None))
29
- if len(args) == 1:
30
- return args[0] # single type left
31
- else:
32
- return Union[args] # multi-type union remains
33
- return typ
34
-
35
- for field in fields(cls):
36
- # property must be in given json!
37
- value = data.get(field.name)
38
-
39
- inner_type = unwrap_optional(field.type)
40
-
41
- # Handle None
42
- if value is None:
43
- kwargs[field.name] = None
44
- # Integers stored as strings
45
- elif isinstance(value, str) and value.isdigit():
46
- kwargs[field.name] = int(value)
47
- # Nested dataclass
48
- elif is_dataclass(inner_type):
49
- kwargs[field.name] = inner_type.from_dict(value, http)
50
- # List type
51
- elif get_origin(inner_type) is list:
52
- list_type = get_args(inner_type)[0]
53
- kwargs[field.name] = [
54
- list_type.from_dict(v, http) if is_dataclass(list_type) else v
55
- for v in value
56
- ]
57
- # Everything else (primitive, Union of primitives)
58
- else:
59
- kwargs[field.name] = value
60
-
61
- instance = cls(**kwargs)
62
-
63
- # attach HTTP if given
64
- if http:
65
- instance._http = http
66
-
67
- return instance
68
-
69
- def _to_dict(self):
70
- """Recursively turns the dataclass into a dictionary and drops empty fields.
71
-
72
- Returns:
73
- (dict): serialized dataclasss
74
- """
75
- def serialize(value):
76
- if isinstance(value, list):
77
- return [serialize(v) for v in value]
78
- if isinstance(value, DataModel):
79
- return value._to_dict()
80
- return value
81
-
82
- result = {}
83
- for f in fields(self):
84
- if f.name.startswith('_'): # ignore private fields
85
- continue
86
- value = getattr(self, f.name)
87
- if value not in (None, [], {}, "", 0):
88
- result[f.name] = serialize(value)
89
-
90
- return result
@@ -1 +0,0 @@
1
- # discord/models
@@ -1,37 +0,0 @@
1
- from dataclasses import dataclass
2
- from ..model import DataModel
3
- from .user import UserModel
4
- from .guild import GuildModel
5
-
6
- @dataclass
7
- class ApplicationModel(DataModel):
8
- """Represents a bot application object."""
9
- id: int
10
- """ID of the app."""
11
-
12
- name: str
13
- """Name of the app."""
14
-
15
- icon: str
16
- """Icon hash of the app."""
17
-
18
- description: str
19
- """Description of the app."""
20
-
21
- bot_public: bool
22
- """If other users can add this app to a guild."""
23
-
24
- bot: UserModel
25
- """Partial user obhect for the bot user associated with the app."""
26
-
27
- owner: UserModel
28
- """Partial user object for the owner of the app."""
29
-
30
- guild_id: int
31
- """Guild ID associated with the app (e.g., a support server)."""
32
-
33
- guild: GuildModel
34
- """Partial guild object of the associated guild."""
35
-
36
- approximate_guild_count: int
37
- """Approximate guild member count."""
discord/models/emoji.py DELETED
@@ -1,34 +0,0 @@
1
- from dataclasses import dataclass
2
- from ..model import DataModel
3
-
4
- from urllib.parse import quote
5
-
6
- @dataclass
7
- class EmojiModel(DataModel):
8
- """Represents a Discord emoji."""
9
- name: str
10
- """Name of emoji."""
11
-
12
- id: int = 0
13
- """ID of the emoji (if custom)."""
14
-
15
- animated: bool = False
16
- """If the emoji is animated. Defaults to `False`."""
17
-
18
- @property
19
- def mention(self) -> str:
20
- """For use in message content."""
21
- return f"<a:{self.name}:{self.id}>" if self.animated else f"<:{self.name}:{self.id}>"
22
-
23
- @property
24
- def api_code(self) -> str:
25
- """Return the correct API code for this emoji (URL-safe)."""
26
- if not self.id:
27
- # unicode emoji
28
- return quote(self.name)
29
-
30
- # custom emoji
31
- if self.animated:
32
- return quote(f"a:{self.name}:{self.id}")
33
-
34
- return quote(f"{self.name}:{self.id}")
discord/models/guild.py DELETED
@@ -1,35 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Optional
3
- from ..model import DataModel
4
- from .emoji import EmojiModel
5
-
6
- @dataclass
7
- class ReadyGuildModel(DataModel):
8
- """Guild info from Ready event."""
9
- id: int
10
- """ID of the associated guild."""
11
-
12
- unavailable: bool
13
- """If the guild is offline."""
14
-
15
- @dataclass
16
- class GuildModel(DataModel):
17
- """Represents a Discord guild."""
18
-
19
- id: int
20
- """ID of the guild."""
21
-
22
- name: str
23
- """Name of the guild."""
24
-
25
- icon: Optional[str] = None
26
- """Icon hash of the guild."""
27
-
28
- emojis: list[EmojiModel] = None
29
- """List of emojis reigstered in the guild."""
30
-
31
- approximate_member_count: Optional[int] = None
32
- """Approximate member count."""
33
-
34
- description: str = None
35
- """Description of the guild."""
@@ -1,23 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Optional
3
- from ..model import DataModel
4
- from .application import ApplicationModel
5
-
6
- @dataclass
7
- class IntegrationModel(DataModel):
8
- """Represents a guild integration."""
9
-
10
- id: int
11
- """ID of the integration."""
12
-
13
- name: str
14
- """Name of the integration."""
15
-
16
- type: str
17
- """Type of integration (e.g., twitch, youtube, discord, or guild_subscription)."""
18
-
19
- enabled: bool
20
- """If the integration is enabled."""
21
-
22
- application: Optional[ApplicationModel] = None
23
- """The bot aaplication for Discord integrations."""
@@ -1,26 +0,0 @@
1
- from dataclasses import dataclass
2
- from ..model import DataModel
3
-
4
- @dataclass
5
- class InteractionCallbackDataModel(DataModel):
6
- id: int
7
- """ID of the interaction."""
8
-
9
- type: int
10
- """Type of interaction."""
11
-
12
- activity_instance_id: str
13
- """Instance ID of activity if an activity was launched or joined."""
14
-
15
- response_message_id: int
16
- """ID of the message created by the interaction."""
17
-
18
- response_message_loading: bool
19
- """If the interaction is in a loading state."""
20
-
21
- response_message_ephemeral: bool
22
- """If the interaction is ephemeral."""
23
-
24
- @dataclass
25
- class InteractionCallbackModel(DataModel):
26
- interaction: InteractionCallbackDataModel
discord/models/member.py DELETED
@@ -1,27 +0,0 @@
1
- from dataclasses import dataclass
2
- from ..model import DataModel
3
- from ..models.user import UserModel
4
-
5
- @dataclass
6
- class MemberModel(DataModel):
7
- """Represents a guild member."""
8
-
9
- roles: list[int]
10
- """List of roles registered to the guild member."""
11
-
12
- user: UserModel
13
- """User data associated with the guild member."""
14
-
15
- nick: str
16
- """Server nickname of the guild member."""
17
-
18
- avatar: str
19
- """Server avatar hash of the guild mmeber."""
20
-
21
- joined_at: str
22
- """ISO8601 timestamp of when the guild member joined server."""
23
- deaf: bool
24
- """If the member is deaf in a VC (input)."""
25
-
26
- mute: bool
27
- """If the member is muted in VC (output)."""
discord/models/role.py DELETED
@@ -1,53 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Optional
3
- from ..model import DataModel
4
-
5
- @dataclass
6
- class RoleColors(DataModel):
7
- """Role color data."""
8
-
9
- primary_color: int
10
- """Primary color of the role."""
11
-
12
- secondary_color: int
13
- """Secondary color of the role. Creates a gradient."""
14
-
15
- tertiary_color: int
16
- """Tertiary color of the role. Creates a holographic style."""
17
-
18
- @dataclass
19
- class RoleModel(DataModel):
20
- """Represents a Discord role."""
21
-
22
- id: int
23
- """ID of the role."""
24
-
25
- name: str
26
- """Name of the role."""
27
-
28
- colors: RoleColors
29
- """Colors of the role."""
30
-
31
- hoist: bool
32
- """If the role is pinned in user listing."""
33
-
34
- position: int
35
- """Position of the role."""
36
-
37
- permissions: str
38
- """Permission bit set."""
39
-
40
- managed: bool
41
- """If the role is managed by an integration."""
42
-
43
- mentionable: bool
44
- """If the role is mentionable."""
45
-
46
- flags: int
47
- """Role flags combined as a bitfield."""
48
-
49
- icon: Optional[str] = None
50
- """Icon hash of the role."""
51
-
52
- unicode_emoji: Optional[str] = None
53
- """Unicode emoji of the role."""
discord/models/user.py DELETED
@@ -1,15 +0,0 @@
1
- from dataclasses import dataclass
2
-
3
- from ..model import DataModel
4
-
5
- @dataclass
6
- class UserModel(DataModel):
7
- """Describes the User object."""
8
- id: int
9
- """ID of the user."""
10
-
11
- username: str
12
- """Username of the user."""
13
-
14
- avatar: str
15
- """Avatar hash of the user."""