disagreement 0.0.1__py3-none-any.whl → 0.0.2__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.
@@ -0,0 +1,478 @@
1
+ # disagreement/ext/app_commands/converters.py
2
+
3
+ """
4
+ Converters for transforming application command option values.
5
+ """
6
+
7
+ from typing import (
8
+ Any,
9
+ Awaitable,
10
+ Callable,
11
+ Dict,
12
+ Protocol,
13
+ TypeVar,
14
+ Union,
15
+ TYPE_CHECKING,
16
+ )
17
+ from disagreement.enums import ApplicationCommandOptionType
18
+ from disagreement.errors import (
19
+ AppCommandOptionConversionError,
20
+ ) # To be created in disagreement/errors.py
21
+
22
+ if TYPE_CHECKING:
23
+ from disagreement.interactions import Interaction # For context if needed
24
+ from disagreement.models import (
25
+ User,
26
+ Member,
27
+ Role,
28
+ Channel,
29
+ Attachment,
30
+ ) # Discord models
31
+ from disagreement.client import Client # For fetching objects
32
+
33
+ T = TypeVar("T", covariant=True)
34
+
35
+
36
+ class Converter(Protocol[T]):
37
+ """
38
+ A protocol for classes that can convert an interaction option value to a specific type.
39
+ """
40
+
41
+ async def convert(self, interaction: "Interaction", value: Any) -> T:
42
+ """
43
+ Converts the given value to the target type.
44
+
45
+ Parameters:
46
+ interaction (Interaction): The interaction context.
47
+ value (Any): The raw value from the interaction option.
48
+
49
+ Returns:
50
+ T: The converted value.
51
+
52
+ Raises:
53
+ AppCommandOptionConversionError: If conversion fails.
54
+ """
55
+ ...
56
+
57
+
58
+ # Basic Type Converters
59
+
60
+
61
+ class StringConverter(Converter[str]):
62
+ async def convert(self, interaction: "Interaction", value: Any) -> str:
63
+ if not isinstance(value, str):
64
+ raise AppCommandOptionConversionError(
65
+ f"Expected a string, but got {type(value).__name__}: {value}"
66
+ )
67
+ return value
68
+
69
+
70
+ class IntegerConverter(Converter[int]):
71
+ async def convert(self, interaction: "Interaction", value: Any) -> int:
72
+ if not isinstance(value, int):
73
+ try:
74
+ return int(value)
75
+ except (ValueError, TypeError):
76
+ raise AppCommandOptionConversionError(
77
+ f"Expected an integer, but got {type(value).__name__}: {value}"
78
+ )
79
+ return value
80
+
81
+
82
+ class BooleanConverter(Converter[bool]):
83
+ async def convert(self, interaction: "Interaction", value: Any) -> bool:
84
+ if not isinstance(value, bool):
85
+ if isinstance(value, str):
86
+ if value.lower() == "true":
87
+ return True
88
+ elif value.lower() == "false":
89
+ return False
90
+ raise AppCommandOptionConversionError(
91
+ f"Expected a boolean, but got {type(value).__name__}: {value}"
92
+ )
93
+ return value
94
+
95
+
96
+ class NumberConverter(Converter[float]): # Discord 'NUMBER' type is float
97
+ async def convert(self, interaction: "Interaction", value: Any) -> float:
98
+ if not isinstance(value, (int, float)):
99
+ try:
100
+ return float(value)
101
+ except (ValueError, TypeError):
102
+ raise AppCommandOptionConversionError(
103
+ f"Expected a number (float), but got {type(value).__name__}: {value}"
104
+ )
105
+ return float(value) # Ensure it's a float even if int is passed
106
+
107
+
108
+ # Discord Model Converters
109
+
110
+
111
+ class UserConverter(Converter["User"]):
112
+ def __init__(self, client: "Client"):
113
+ self._client = client
114
+
115
+ async def convert(self, interaction: "Interaction", value: Any) -> "User":
116
+ if isinstance(value, str): # Assume it's a user ID
117
+ user_id = value
118
+ # Attempt to get from interaction resolved data first
119
+ if (
120
+ interaction.data
121
+ and interaction.data.resolved
122
+ and interaction.data.resolved.users
123
+ ):
124
+ user_object = interaction.data.resolved.users.get(
125
+ user_id
126
+ ) # This is already a User object
127
+ if user_object:
128
+ return user_object # Return the already parsed User object
129
+
130
+ # Fallback to fetching if not in resolved or if interaction has no resolved data
131
+ try:
132
+ user = await self._client.fetch_user(
133
+ user_id
134
+ ) # fetch_user now also parses and caches
135
+ if user:
136
+ return user
137
+ raise AppCommandOptionConversionError(
138
+ f"User with ID '{user_id}' not found.",
139
+ option_name="user",
140
+ original_value=value,
141
+ )
142
+ except Exception as e: # Catch potential HTTP errors from fetch_user
143
+ raise AppCommandOptionConversionError(
144
+ f"Failed to fetch user '{user_id}': {e}",
145
+ option_name="user",
146
+ original_value=value,
147
+ )
148
+ elif (
149
+ isinstance(value, dict) and "id" in value
150
+ ): # If it's raw user data dict (less common path now)
151
+ return self._client.parse_user(value) # parse_user handles dict -> User
152
+ raise AppCommandOptionConversionError(
153
+ f"Expected a user ID string or user data dict, got {type(value).__name__}",
154
+ option_name="user",
155
+ original_value=value,
156
+ )
157
+
158
+
159
+ class MemberConverter(Converter["Member"]):
160
+ def __init__(self, client: "Client"):
161
+ self._client = client
162
+
163
+ async def convert(self, interaction: "Interaction", value: Any) -> "Member":
164
+ if not interaction.guild_id:
165
+ raise AppCommandOptionConversionError(
166
+ "Cannot convert to Member outside of a guild context.",
167
+ option_name="member",
168
+ )
169
+
170
+ if isinstance(value, str): # Assume it's a user ID
171
+ member_id = value
172
+ # Attempt to get from interaction resolved data first
173
+ if (
174
+ interaction.data
175
+ and interaction.data.resolved
176
+ and interaction.data.resolved.members
177
+ ):
178
+ # The Member object from resolved.members should already be correctly initialized
179
+ # by ResolvedData, including its User part.
180
+ member = interaction.data.resolved.members.get(member_id)
181
+ if member:
182
+ return (
183
+ member # Return the already resolved and parsed Member object
184
+ )
185
+
186
+ # Fallback to fetching if not in resolved
187
+ try:
188
+ member = await self._client.fetch_member(
189
+ interaction.guild_id, member_id
190
+ )
191
+ if member:
192
+ return member
193
+ raise AppCommandOptionConversionError(
194
+ f"Member with ID '{member_id}' not found in guild '{interaction.guild_id}'.",
195
+ option_name="member",
196
+ original_value=value,
197
+ )
198
+ except Exception as e:
199
+ raise AppCommandOptionConversionError(
200
+ f"Failed to fetch member '{member_id}': {e}",
201
+ option_name="member",
202
+ original_value=value,
203
+ )
204
+ elif isinstance(value, dict) and "id" in value.get(
205
+ "user", {}
206
+ ): # If it's already a member data dict
207
+ return self._client.parse_member(value, interaction.guild_id)
208
+ raise AppCommandOptionConversionError(
209
+ f"Expected a member ID string or member data dict, got {type(value).__name__}",
210
+ option_name="member",
211
+ original_value=value,
212
+ )
213
+
214
+
215
+ class RoleConverter(Converter["Role"]):
216
+ def __init__(self, client: "Client"):
217
+ self._client = client
218
+
219
+ async def convert(self, interaction: "Interaction", value: Any) -> "Role":
220
+ if not interaction.guild_id:
221
+ raise AppCommandOptionConversionError(
222
+ "Cannot convert to Role outside of a guild context.", option_name="role"
223
+ )
224
+
225
+ if isinstance(value, str): # Assume it's a role ID
226
+ role_id = value
227
+ # Attempt to get from interaction resolved data first
228
+ if (
229
+ interaction.data
230
+ and interaction.data.resolved
231
+ and interaction.data.resolved.roles
232
+ ):
233
+ role_object = interaction.data.resolved.roles.get(
234
+ role_id
235
+ ) # Should be a Role object
236
+ if role_object:
237
+ return role_object
238
+
239
+ # Fallback to fetching from guild if not in resolved
240
+ # This requires Client to have a fetch_role method or similar
241
+ try:
242
+ # Assuming Client.fetch_role(guild_id, role_id) will be implemented
243
+ role = await self._client.fetch_role(interaction.guild_id, role_id)
244
+ if role:
245
+ return role
246
+ raise AppCommandOptionConversionError(
247
+ f"Role with ID '{role_id}' not found in guild '{interaction.guild_id}'.",
248
+ option_name="role",
249
+ original_value=value,
250
+ )
251
+ except Exception as e:
252
+ raise AppCommandOptionConversionError(
253
+ f"Failed to fetch role '{role_id}': {e}",
254
+ option_name="role",
255
+ original_value=value,
256
+ )
257
+ elif (
258
+ isinstance(value, dict) and "id" in value
259
+ ): # If it's already role data dict
260
+ if (
261
+ not interaction.guild_id
262
+ ): # Should have been caught earlier, but as a safeguard
263
+ raise AppCommandOptionConversionError(
264
+ "Guild context is required to parse role data.",
265
+ option_name="role",
266
+ original_value=value,
267
+ )
268
+ return self._client.parse_role(value, interaction.guild_id)
269
+ # This path is reached if value is not a string (role ID) and not a dict (role data)
270
+ # or if it's a string but all fetching/lookup attempts failed.
271
+ # The final raise AppCommandOptionConversionError should be outside the if/elif for string values.
272
+ # If value was a string, an error should have been raised within the 'if isinstance(value, str)' block.
273
+ # If it wasn't a string or dict, this is the correct place to raise.
274
+ # The previous structure was slightly off, as the final raise was inside the string check block.
275
+ # Let's ensure the final raise is at the correct scope.
276
+ # The current structure seems to imply that if it's not a string, it must be a dict or error.
277
+ # If it's a string and all lookups fail, an error is raised within that block.
278
+ # If it's a dict and parsing fails (or guild_id missing), error raised.
279
+ # If it's neither, this final raise is correct.
280
+ # The "Function with declared return type "Role" must return value on all code paths"
281
+ # error suggests a path where no return or raise happens.
282
+ # This happens if `isinstance(value, str)` is true, but then all internal paths
283
+ # (resolved check, fetch try/except) don't lead to a return or raise *before*
284
+ # falling out of the `if isinstance(value, str)` block.
285
+ # The `raise AppCommandOptionConversionError` at the end of the `if isinstance(value, str)` block
286
+ # (line 156 in previous version) handles the case where a role ID is given but not found.
287
+ # The one at the very end (line 164 in previous) handles cases where value is not str/dict.
288
+
289
+ # Corrected structure for the final raise:
290
+ # It should be at the same level as the initial `if isinstance(value, str):`
291
+ # to catch cases where `value` is neither a str nor a dict.
292
+ # However, the current logic within the `if isinstance(value, str):` block
293
+ # ensures a raise if the role ID is not found.
294
+ # The `elif isinstance(value, dict)` handles the dict case.
295
+ # The final `raise` (line 164) is for types other than str or dict.
296
+ # The Pylance error "Function with declared return type "Role" must return value on all code paths"
297
+ # implies that if `value` is a string, and `interaction.data.resolved.roles.get(role_id)` is None,
298
+ # AND `self._client.fetch_role` returns None (which it can), then the
299
+ # `raise AppCommandOptionConversionError` on line 156 is correctly hit.
300
+ # The issue might be that Pylance doesn't see `AppCommandOptionConversionError` as definitively terminating.
301
+ # This is unlikely. Let's re-verify the logic flow.
302
+
303
+ # The `raise` on line 156 is correct if role_id is not found after fetching.
304
+ # The `raise` on line 164 is for when `value` is not a str and not a dict.
305
+ # This seems logically sound. The Pylance error might be a misinterpretation or a subtle issue.
306
+ # For now, the duplicated `except` is the primary syntax error.
307
+ # The "must return value on all code paths" often occurs if an if/elif chain doesn't
308
+ # exhaust all possibilities or if a path through a try/except doesn't guarantee a return/raise.
309
+ # In this case, if `value` is a string, it either returns a Role or raises an error.
310
+ # If `value` is a dict, it either returns a Role or raises an error.
311
+ # If `value` is neither, it raises an error. All paths seem covered.
312
+ # The syntax error from the duplicated `except` is the most likely culprit for Pylance's confusion.
313
+ raise AppCommandOptionConversionError(
314
+ f"Expected a role ID string or role data dict, got {type(value).__name__}",
315
+ option_name="role",
316
+ original_value=value,
317
+ )
318
+
319
+
320
+ class ChannelConverter(Converter["Channel"]):
321
+ def __init__(self, client: "Client"):
322
+ self._client = client
323
+
324
+ async def convert(self, interaction: "Interaction", value: Any) -> "Channel":
325
+ if isinstance(value, str): # Assume it's a channel ID
326
+ channel_id = value
327
+ # Attempt to get from interaction resolved data first
328
+ if (
329
+ interaction.data
330
+ and interaction.data.resolved
331
+ and interaction.data.resolved.channels
332
+ ):
333
+ # Resolved channels are PartialChannel. Client.fetch_channel will get the full typed one.
334
+ partial_channel = interaction.data.resolved.channels.get(channel_id)
335
+ if partial_channel:
336
+ # Client.fetch_channel should handle fetching and parsing to the correct Channel subtype
337
+ full_channel = await self._client.fetch_channel(partial_channel.id)
338
+ if full_channel:
339
+ return full_channel
340
+ # If fetch_channel returns None even with a resolved ID, it's an issue.
341
+ raise AppCommandOptionConversionError(
342
+ f"Failed to fetch full channel for resolved ID '{channel_id}'.",
343
+ option_name="channel",
344
+ original_value=value,
345
+ )
346
+
347
+ # Fallback to fetching directly if not in resolved or if resolved fetch failed
348
+ try:
349
+ channel = await self._client.fetch_channel(
350
+ channel_id
351
+ ) # fetch_channel handles parsing
352
+ if channel:
353
+ return channel
354
+ raise AppCommandOptionConversionError(
355
+ f"Channel with ID '{channel_id}' not found.",
356
+ option_name="channel",
357
+ original_value=value,
358
+ )
359
+ except Exception as e:
360
+ raise AppCommandOptionConversionError(
361
+ f"Failed to fetch channel '{channel_id}': {e}",
362
+ option_name="channel",
363
+ original_value=value,
364
+ )
365
+ # Raw channel data dicts are not typically provided for slash command options.
366
+ raise AppCommandOptionConversionError(
367
+ f"Expected a channel ID string, got {type(value).__name__}",
368
+ option_name="channel",
369
+ original_value=value,
370
+ )
371
+
372
+
373
+ class AttachmentConverter(Converter["Attachment"]):
374
+ def __init__(
375
+ self, client: "Client"
376
+ ): # Client might be needed for future enhancements or consistency
377
+ self._client = client
378
+
379
+ async def convert(self, interaction: "Interaction", value: Any) -> "Attachment":
380
+ if isinstance(value, str): # Value is the attachment ID
381
+ attachment_id = value
382
+ if (
383
+ interaction.data
384
+ and interaction.data.resolved
385
+ and interaction.data.resolved.attachments
386
+ ):
387
+ attachment_object = interaction.data.resolved.attachments.get(
388
+ attachment_id
389
+ ) # This is already an Attachment object
390
+ if attachment_object:
391
+ return (
392
+ attachment_object # Return the already parsed Attachment object
393
+ )
394
+ raise AppCommandOptionConversionError(
395
+ f"Attachment with ID '{attachment_id}' not found in resolved data.",
396
+ option_name="attachment",
397
+ original_value=value,
398
+ )
399
+ raise AppCommandOptionConversionError(
400
+ f"Expected an attachment ID string, got {type(value).__name__}",
401
+ option_name="attachment",
402
+ original_value=value,
403
+ )
404
+
405
+
406
+ # Converters can be registered dynamically using
407
+ # :meth:`disagreement.ext.app_commands.handler.AppCommandHandler.register_converter`.
408
+
409
+ # Mapping from ApplicationCommandOptionType to default converters
410
+ # This will be used by the AppCommandHandler to automatically apply converters
411
+ # if no explicit converter is specified for a command option's type hint.
412
+ DEFAULT_CONVERTERS: Dict[
413
+ ApplicationCommandOptionType, Callable[..., Converter[Any]]
414
+ ] = { # Changed Callable signature
415
+ ApplicationCommandOptionType.STRING: StringConverter,
416
+ ApplicationCommandOptionType.INTEGER: IntegerConverter,
417
+ ApplicationCommandOptionType.BOOLEAN: BooleanConverter,
418
+ ApplicationCommandOptionType.NUMBER: NumberConverter,
419
+ ApplicationCommandOptionType.USER: UserConverter,
420
+ ApplicationCommandOptionType.CHANNEL: ChannelConverter,
421
+ ApplicationCommandOptionType.ROLE: RoleConverter,
422
+ # ApplicationCommandOptionType.MENTIONABLE: MentionableConverter, # Special case, can be User or Role
423
+ ApplicationCommandOptionType.ATTACHMENT: AttachmentConverter, # Added
424
+ }
425
+
426
+
427
+ async def run_converters(
428
+ interaction: "Interaction",
429
+ param_type: Any, # The type hint of the parameter
430
+ option_type: ApplicationCommandOptionType, # The Discord option type
431
+ value: Any,
432
+ client: "Client", # Needed for model converters
433
+ ) -> Any:
434
+ """
435
+ Runs the appropriate converter for a given parameter type and value.
436
+ This function will be more complex, handling custom converters, unions, optionals etc.
437
+ For now, a basic lookup.
438
+ """
439
+ converter_class_factory = DEFAULT_CONVERTERS.get(option_type)
440
+ if converter_class_factory:
441
+ # Check if the factory needs the client instance
442
+ # This is a bit simplistic; a more robust way might involve inspecting __init__ signature
443
+ # or having converters register their needs.
444
+ if option_type in [
445
+ ApplicationCommandOptionType.USER,
446
+ ApplicationCommandOptionType.CHANNEL, # Anticipating these
447
+ ApplicationCommandOptionType.ROLE,
448
+ ApplicationCommandOptionType.MENTIONABLE,
449
+ ApplicationCommandOptionType.ATTACHMENT,
450
+ ]:
451
+ converter_instance = converter_class_factory(client=client)
452
+ else:
453
+ converter_instance = converter_class_factory()
454
+
455
+ return await converter_instance.convert(interaction, value)
456
+
457
+ # Fallback for unhandled types or if direct type matching is needed
458
+ if param_type is str and isinstance(value, str):
459
+ return value
460
+ if param_type is int and isinstance(value, int):
461
+ return value
462
+ if param_type is bool and isinstance(value, bool):
463
+ return value
464
+ if param_type is float and isinstance(value, (float, int)):
465
+ return float(value)
466
+
467
+ # If no specific converter, and it's not a basic type match, raise error or return raw
468
+ # For now, let's raise if no converter found for a specific option type
469
+ if option_type in DEFAULT_CONVERTERS: # Should have been handled
470
+ pass # This path implies a logic error above or missing converter in DEFAULT_CONVERTERS
471
+
472
+ # If it's a model type but no converter yet, this will need to be handled
473
+ # e.g. if param_type is User and option_type is ApplicationCommandOptionType.USER
474
+
475
+ raise AppCommandOptionConversionError(
476
+ f"No suitable converter found for option type {option_type.name} "
477
+ f"with value '{value}' to target type {param_type.__name__ if hasattr(param_type, '__name__') else param_type}"
478
+ )