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,569 @@
1
+ # disagreement/ext/app_commands/decorators.py
2
+
3
+ import inspect
4
+ import asyncio
5
+ from dataclasses import dataclass
6
+ from typing import (
7
+ Callable,
8
+ Optional,
9
+ List,
10
+ Dict,
11
+ Any,
12
+ Union,
13
+ Type,
14
+ get_origin,
15
+ get_args,
16
+ TYPE_CHECKING,
17
+ Literal,
18
+ Annotated,
19
+ TypeVar,
20
+ cast,
21
+ )
22
+
23
+ from .commands import (
24
+ SlashCommand,
25
+ UserCommand,
26
+ MessageCommand,
27
+ AppCommand,
28
+ AppCommandGroup,
29
+ HybridCommand,
30
+ )
31
+ from disagreement.interactions import (
32
+ ApplicationCommandOption,
33
+ ApplicationCommandOptionChoice,
34
+ Snowflake,
35
+ )
36
+ from disagreement.enums import (
37
+ ApplicationCommandOptionType,
38
+ IntegrationType,
39
+ InteractionContextType,
40
+ # Assuming ChannelType will be added to disagreement.enums
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from disagreement.client import Client # For potential future use
45
+ from disagreement.models import Channel, User
46
+
47
+ # Assuming TextChannel, VoiceChannel etc. might be defined or aliased
48
+ # For now, we'll use string comparisons for channel types or rely on a yet-to-be-defined ChannelType enum
49
+ Channel = Any
50
+ Member = Any
51
+ Role = Any
52
+ Attachment = Any
53
+ # from .cog import Cog # Placeholder
54
+ else:
55
+ # Runtime fallbacks for optional model classes
56
+ from disagreement.models import Channel
57
+
58
+ Client = Any # type: ignore
59
+ User = Any # type: ignore
60
+ Member = Any # type: ignore
61
+ Role = Any # type: ignore
62
+ Attachment = Any # type: ignore
63
+
64
+ # Mapping Python types to Discord ApplicationCommandOptionType
65
+ # This will need to be expanded and made more robust.
66
+ # Consider using a registry or a more sophisticated type mapping system.
67
+ _type_mapping: Dict[Any, ApplicationCommandOptionType] = (
68
+ { # Changed Type to Any for key due to placeholders
69
+ str: ApplicationCommandOptionType.STRING,
70
+ int: ApplicationCommandOptionType.INTEGER,
71
+ bool: ApplicationCommandOptionType.BOOLEAN,
72
+ float: ApplicationCommandOptionType.NUMBER, # Discord 'NUMBER' type is for float/double
73
+ User: ApplicationCommandOptionType.USER,
74
+ Channel: ApplicationCommandOptionType.CHANNEL,
75
+ # Placeholders for actual model types from disagreement.models
76
+ # These will be resolved to their actual types when TYPE_CHECKING is False or via isinstance checks
77
+ }
78
+ )
79
+
80
+
81
+ # Helper dataclass for storing extra option metadata
82
+ @dataclass
83
+ class OptionMetadata:
84
+ channel_types: Optional[List[int]] = None
85
+ min_value: Optional[Union[int, float]] = None
86
+ max_value: Optional[Union[int, float]] = None
87
+ min_length: Optional[int] = None
88
+ max_length: Optional[int] = None
89
+ autocomplete: bool = False
90
+
91
+
92
+ # Ensure these are updated if model names/locations change
93
+ if TYPE_CHECKING:
94
+ # _type_mapping[User] = ApplicationCommandOptionType.USER # Already added above
95
+ _type_mapping[Member] = ApplicationCommandOptionType.USER # Member implies User
96
+ _type_mapping[Role] = ApplicationCommandOptionType.ROLE
97
+ _type_mapping[Attachment] = ApplicationCommandOptionType.ATTACHMENT
98
+ _type_mapping[Channel] = ApplicationCommandOptionType.CHANNEL
99
+
100
+ # TypeVar for the app command decorator factory
101
+ AppCmdType = TypeVar("AppCmdType", bound=AppCommand)
102
+
103
+
104
+ def _extract_options_from_signature(
105
+ func: Callable[..., Any], option_meta: Optional[Dict[str, OptionMetadata]] = None
106
+ ) -> List[ApplicationCommandOption]:
107
+ """
108
+ Inspects a function signature and generates ApplicationCommandOption list.
109
+ """
110
+ options: List[ApplicationCommandOption] = []
111
+ params = inspect.signature(func).parameters
112
+
113
+ doc = inspect.getdoc(func)
114
+ param_descriptions: Dict[str, str] = {}
115
+ if doc:
116
+ for line in inspect.cleandoc(doc).splitlines():
117
+ line = line.strip()
118
+ if line.startswith(":param"):
119
+ try:
120
+ _, rest = line.split(" ", 1)
121
+ name, desc = rest.split(":", 1)
122
+ param_descriptions[name.strip()] = desc.strip()
123
+ except ValueError:
124
+ continue
125
+
126
+ # Skip 'self' (for cogs) and 'ctx' (context) parameters
127
+ param_iter = iter(params.values())
128
+ first_param = next(param_iter, None)
129
+
130
+ # Heuristic: if the function is bound to a class (cog), 'self' might be the first param.
131
+ # A more robust way would be to check if `func` is a method of a Cog instance later.
132
+ # For now, simple name check.
133
+ if first_param and first_param.name == "self":
134
+ first_param = next(param_iter, None) # Consume 'self', get next
135
+
136
+ if first_param and first_param.name == "ctx": # Consume 'ctx'
137
+ pass # ctx is handled, now iterate over actual command options
138
+ elif (
139
+ first_param
140
+ ): # If first_param was not 'self' and not 'ctx', it's a command option
141
+ param_iter = iter(params.values()) # Reset iterator to include the first param
142
+
143
+ for param in param_iter:
144
+ if param.name == "self" or param.name == "ctx": # Should have been skipped
145
+ continue
146
+
147
+ if param.kind == param.VAR_POSITIONAL or param.kind == param.VAR_KEYWORD:
148
+ # *args and **kwargs are not directly supported by slash command options structure.
149
+ # Could raise an error or ignore. For now, ignore.
150
+ # print(f"Warning: *args/**kwargs ({param.name}) are not supported for slash command options.")
151
+ continue
152
+
153
+ option_name = param.name
154
+ option_description = param_descriptions.get(
155
+ option_name, f"Description for {option_name}"
156
+ )
157
+ meta = option_meta.get(option_name) if option_meta else None
158
+
159
+ param_type_hint = param.annotation
160
+ if param_type_hint == inspect.Parameter.empty:
161
+ # Default to string if no type hint, or raise error.
162
+ # Forcing type hints is generally better for slash commands.
163
+ # raise TypeError(f"Option '{option_name}' must have a type hint for slash commands.")
164
+ param_type_hint = str # Defaulting to string, can be made stricter
165
+
166
+ option_type: Optional[ApplicationCommandOptionType] = None
167
+ choices: Optional[List[ApplicationCommandOptionChoice]] = None
168
+
169
+ origin = get_origin(param_type_hint)
170
+ args = get_args(param_type_hint)
171
+
172
+ if origin is Annotated:
173
+ param_type_hint = args[0]
174
+ for extra in args[1:]:
175
+ if isinstance(extra, OptionMetadata):
176
+ meta = extra
177
+ origin = get_origin(param_type_hint)
178
+ args = get_args(param_type_hint)
179
+
180
+ actual_type_for_mapping = param_type_hint
181
+ is_optional = False
182
+
183
+ if origin is Union: # Handles Optional[T] which is Union[T, NoneType]
184
+ # Filter out NoneType to get the actual type for mapping
185
+ union_types = [t for t in args if t is not type(None)]
186
+ if len(union_types) == 1:
187
+ actual_type_for_mapping = union_types[0]
188
+ is_optional = True
189
+ else:
190
+ # More complex Unions are not directly supported by a single option type.
191
+ # Could default to STRING or raise.
192
+ # For now, let's assume simple Optional[T] or direct types.
193
+ # print(f"Warning: Complex Union type for '{option_name}' not fully supported, defaulting to STRING.")
194
+ actual_type_for_mapping = str
195
+
196
+ elif origin is list and len(args) == 1:
197
+ # List[T] is not a direct option type. Discord handles multiple values for some types
198
+ # via repeated options or specific component interactions, not directly in slash command options.
199
+ # This might indicate a need for a different interaction pattern or custom parsing.
200
+ # For now, treat List[str] as a string, others might error or default.
201
+ # print(f"Warning: List type for '{option_name}' not directly supported as a single option. Consider type {args[0]}.")
202
+ actual_type_for_mapping = args[
203
+ 0
204
+ ] # Use the inner type for mapping, but this is a simplification.
205
+
206
+ if origin is Literal: # typing.Literal['a', 'b']
207
+ choices = []
208
+ for choice_val in args:
209
+ if not isinstance(choice_val, (str, int, float)):
210
+ raise TypeError(
211
+ f"Literal choices for '{option_name}' must be str, int, or float. Got {type(choice_val)}."
212
+ )
213
+ choices.append(
214
+ ApplicationCommandOptionChoice(
215
+ data={"name": str(choice_val), "value": choice_val}
216
+ )
217
+ )
218
+ # The type of the Literal's arguments determines the option type
219
+ if choices:
220
+ literal_arg_type = type(choices[0].value)
221
+ option_type = _type_mapping.get(literal_arg_type)
222
+ if (
223
+ not option_type and literal_arg_type is float
224
+ ): # float maps to NUMBER
225
+ option_type = ApplicationCommandOptionType.NUMBER
226
+
227
+ if not option_type: # If not determined by Literal
228
+ option_type = _type_mapping.get(actual_type_for_mapping)
229
+ # Special handling for User, Member, Role, Attachment, Channel if not directly in _type_mapping
230
+ # This is a bit crude; a proper registry or isinstance checks would be better.
231
+ if not option_type:
232
+ if (
233
+ actual_type_for_mapping.__name__ == "User"
234
+ or actual_type_for_mapping.__name__ == "Member"
235
+ ):
236
+ option_type = ApplicationCommandOptionType.USER
237
+ elif actual_type_for_mapping.__name__ == "Role":
238
+ option_type = ApplicationCommandOptionType.ROLE
239
+ elif actual_type_for_mapping.__name__ == "Attachment":
240
+ option_type = ApplicationCommandOptionType.ATTACHMENT
241
+ elif (
242
+ inspect.isclass(actual_type_for_mapping)
243
+ and isinstance(Channel, type)
244
+ and issubclass(actual_type_for_mapping, cast(type, Channel))
245
+ ):
246
+ option_type = ApplicationCommandOptionType.CHANNEL
247
+
248
+ if not option_type:
249
+ # Fallback or error if type couldn't be mapped
250
+ # print(f"Warning: Could not map type '{actual_type_for_mapping}' for option '{option_name}'. Defaulting to STRING.")
251
+ option_type = ApplicationCommandOptionType.STRING # Default fallback
252
+
253
+ required = (param.default == inspect.Parameter.empty) and not is_optional
254
+
255
+ data: Dict[str, Any] = {
256
+ "name": option_name,
257
+ "description": option_description,
258
+ "type": option_type.value,
259
+ "required": required,
260
+ "choices": ([c.to_dict() for c in choices] if choices else None),
261
+ }
262
+
263
+ if meta:
264
+ if meta.channel_types is not None:
265
+ data["channel_types"] = meta.channel_types
266
+ if meta.min_value is not None:
267
+ data["min_value"] = meta.min_value
268
+ if meta.max_value is not None:
269
+ data["max_value"] = meta.max_value
270
+ if meta.min_length is not None:
271
+ data["min_length"] = meta.min_length
272
+ if meta.max_length is not None:
273
+ data["max_length"] = meta.max_length
274
+ if meta.autocomplete:
275
+ data["autocomplete"] = True
276
+
277
+ options.append(ApplicationCommandOption(data=data))
278
+ return options
279
+
280
+
281
+ def _app_command_decorator(
282
+ cls: Type[AppCmdType],
283
+ option_meta: Optional[Dict[str, OptionMetadata]] = None,
284
+ **attrs: Any,
285
+ ) -> Callable[[Callable[..., Any]], AppCmdType]:
286
+ """Generic factory for creating app command decorators."""
287
+
288
+ def decorator(func: Callable[..., Any]) -> AppCmdType:
289
+ if not asyncio.iscoroutinefunction(func):
290
+ raise TypeError(
291
+ "Application command callback must be a coroutine function."
292
+ )
293
+
294
+ name = attrs.pop("name", None) or func.__name__
295
+ description = attrs.pop("description", None) or inspect.getdoc(func)
296
+ if description: # Clean up docstring
297
+ description = inspect.cleandoc(description).split("\n\n", 1)[
298
+ 0
299
+ ] # Use first paragraph
300
+
301
+ parent_group = attrs.pop("parent", None)
302
+ if parent_group and not isinstance(parent_group, AppCommandGroup):
303
+ raise TypeError(
304
+ "The 'parent' argument must be an AppCommandGroup instance."
305
+ )
306
+
307
+ # For User/Message commands, description should be empty for payload, but can be stored for help.
308
+ if cls is UserCommand or cls is MessageCommand:
309
+ actual_description_for_payload = ""
310
+ else:
311
+ actual_description_for_payload = description
312
+ if not actual_description_for_payload and cls is SlashCommand:
313
+ raise ValueError(f"Slash command '{name}' must have a description.")
314
+
315
+ # Create the command instance
316
+ cmd_instance = cls(
317
+ callback=func,
318
+ name=name,
319
+ description=actual_description_for_payload, # Use payload-appropriate description
320
+ **attrs, # Remaining attributes like guild_ids, nsfw, etc.
321
+ )
322
+
323
+ # Store original description if different (e.g. for User/Message commands for help text)
324
+ if description != actual_description_for_payload:
325
+ cmd_instance._full_description = (
326
+ description # Custom attribute for library use
327
+ )
328
+
329
+ if isinstance(cmd_instance, SlashCommand):
330
+ cmd_instance.options = _extract_options_from_signature(func, option_meta)
331
+
332
+ if parent_group:
333
+ parent_group.add_command(cmd_instance) # This also sets cmd_instance.parent
334
+
335
+ # Attach command object to the function for later collection by Cog or Client
336
+ # This is a common pattern.
337
+ if hasattr(func, "__app_command_object__"):
338
+ # Function might already be decorated (e.g. hybrid or stacked decorators)
339
+ # Decide on behavior: error, overwrite, or store list of commands.
340
+ # For now, let's assume one app command decorator of a specific type per function.
341
+ # Hybrid commands will need special handling.
342
+ print(
343
+ f"Warning: Function {func.__name__} is already an app command or has one attached. Overwriting."
344
+ )
345
+
346
+ setattr(func, "__app_command_object__", cmd_instance)
347
+ setattr(cmd_instance, "__app_command_object__", cmd_instance)
348
+
349
+ # If the command is a HybridCommand, also set the attribute
350
+ # that the prefix command system's Cog._inject looks for.
351
+ if isinstance(cmd_instance, HybridCommand):
352
+ setattr(func, "__command_object__", cmd_instance)
353
+ setattr(cmd_instance, "__command_object__", cmd_instance)
354
+
355
+ return cmd_instance # Return the command instance itself, not the function
356
+ # This allows it to be added to cogs/handlers directly.
357
+
358
+ return decorator
359
+
360
+
361
+ def slash_command(
362
+ name: Optional[str] = None,
363
+ description: Optional[str] = None,
364
+ guild_ids: Optional[List[Snowflake]] = None,
365
+ default_member_permissions: Optional[str] = None,
366
+ nsfw: bool = False,
367
+ name_localizations: Optional[Dict[str, str]] = None,
368
+ description_localizations: Optional[Dict[str, str]] = None,
369
+ integration_types: Optional[List[IntegrationType]] = None,
370
+ contexts: Optional[List[InteractionContextType]] = None,
371
+ *,
372
+ guilds: bool = True,
373
+ dms: bool = True,
374
+ private_channels: bool = True,
375
+ parent: Optional[AppCommandGroup] = None, # Added parent parameter
376
+ locale: Optional[str] = None,
377
+ option_meta: Optional[Dict[str, OptionMetadata]] = None,
378
+ ) -> Callable[[Callable[..., Any]], SlashCommand]:
379
+ """
380
+ Decorator to create a CHAT_INPUT (slash) command.
381
+ Options are inferred from the function's type hints.
382
+ """
383
+ if contexts is None:
384
+ ctxs: List[InteractionContextType] = []
385
+ if guilds:
386
+ ctxs.append(InteractionContextType.GUILD)
387
+ if dms:
388
+ ctxs.append(InteractionContextType.BOT_DM)
389
+ if private_channels:
390
+ ctxs.append(InteractionContextType.PRIVATE_CHANNEL)
391
+ if len(ctxs) != 3:
392
+ contexts = ctxs
393
+ attrs = {
394
+ "name": name,
395
+ "description": description,
396
+ "guild_ids": guild_ids,
397
+ "default_member_permissions": default_member_permissions,
398
+ "nsfw": nsfw,
399
+ "name_localizations": name_localizations,
400
+ "description_localizations": description_localizations,
401
+ "integration_types": integration_types,
402
+ "contexts": contexts,
403
+ "parent": parent, # Pass parent to attrs
404
+ "locale": locale,
405
+ }
406
+ # Filter out None values to avoid passing them as explicit None to command constructor
407
+ # Keep 'parent' even if None, as _app_command_decorator handles None parent.
408
+ # nsfw default is False, so it's fine if not present and defaults.
409
+ attrs = {k: v for k, v in attrs.items() if v is not None or k in ["nsfw", "parent"]}
410
+ return _app_command_decorator(SlashCommand, option_meta, **attrs)
411
+
412
+
413
+ def user_command(
414
+ name: Optional[str] = None,
415
+ guild_ids: Optional[List[Snowflake]] = None,
416
+ default_member_permissions: Optional[str] = None,
417
+ nsfw: bool = False, # Though less common for user commands
418
+ name_localizations: Optional[Dict[str, str]] = None,
419
+ integration_types: Optional[List[IntegrationType]] = None,
420
+ contexts: Optional[List[InteractionContextType]] = None,
421
+ locale: Optional[str] = None,
422
+ # description is not used by Discord for User commands
423
+ ) -> Callable[[Callable[..., Any]], UserCommand]:
424
+ """Decorator to create a USER context menu command."""
425
+ attrs = {
426
+ "name": name,
427
+ "guild_ids": guild_ids,
428
+ "default_member_permissions": default_member_permissions,
429
+ "nsfw": nsfw,
430
+ "name_localizations": name_localizations,
431
+ "integration_types": integration_types,
432
+ "contexts": contexts,
433
+ "locale": locale,
434
+ }
435
+ attrs = {k: v for k, v in attrs.items() if v is not None or k in ["nsfw"]}
436
+ return _app_command_decorator(UserCommand, **attrs)
437
+
438
+
439
+ def message_command(
440
+ name: Optional[str] = None,
441
+ guild_ids: Optional[List[Snowflake]] = None,
442
+ default_member_permissions: Optional[str] = None,
443
+ nsfw: bool = False, # Though less common for message commands
444
+ name_localizations: Optional[Dict[str, str]] = None,
445
+ integration_types: Optional[List[IntegrationType]] = None,
446
+ contexts: Optional[List[InteractionContextType]] = None,
447
+ locale: Optional[str] = None,
448
+ # description is not used by Discord for Message commands
449
+ ) -> Callable[[Callable[..., Any]], MessageCommand]:
450
+ """Decorator to create a MESSAGE context menu command."""
451
+ attrs = {
452
+ "name": name,
453
+ "guild_ids": guild_ids,
454
+ "default_member_permissions": default_member_permissions,
455
+ "nsfw": nsfw,
456
+ "name_localizations": name_localizations,
457
+ "integration_types": integration_types,
458
+ "contexts": contexts,
459
+ "locale": locale,
460
+ }
461
+ attrs = {k: v for k, v in attrs.items() if v is not None or k in ["nsfw"]}
462
+ return _app_command_decorator(MessageCommand, **attrs)
463
+
464
+
465
+ def hybrid_command(
466
+ name: Optional[str] = None,
467
+ description: Optional[str] = None,
468
+ guild_ids: Optional[List[Snowflake]] = None,
469
+ default_member_permissions: Optional[str] = None,
470
+ nsfw: bool = False,
471
+ name_localizations: Optional[Dict[str, str]] = None,
472
+ description_localizations: Optional[Dict[str, str]] = None,
473
+ integration_types: Optional[List[IntegrationType]] = None,
474
+ contexts: Optional[List[InteractionContextType]] = None,
475
+ *,
476
+ guilds: bool = True,
477
+ dms: bool = True,
478
+ private_channels: bool = True,
479
+ aliases: Optional[List[str]] = None, # Specific to prefix command aspect
480
+ # Other prefix-specific options can be added here (e.g., help, brief)
481
+ option_meta: Optional[Dict[str, OptionMetadata]] = None,
482
+ locale: Optional[str] = None,
483
+ ) -> Callable[[Callable[..., Any]], HybridCommand]:
484
+ """
485
+ Decorator to create a command that can be invoked as both a slash command
486
+ and a traditional prefix-based command.
487
+ Options for the slash command part are inferred from the function's type hints.
488
+ """
489
+ if contexts is None:
490
+ ctxs: List[InteractionContextType] = []
491
+ if guilds:
492
+ ctxs.append(InteractionContextType.GUILD)
493
+ if dms:
494
+ ctxs.append(InteractionContextType.BOT_DM)
495
+ if private_channels:
496
+ ctxs.append(InteractionContextType.PRIVATE_CHANNEL)
497
+ if len(ctxs) != 3:
498
+ contexts = ctxs
499
+ attrs = {
500
+ "name": name,
501
+ "description": description,
502
+ "guild_ids": guild_ids,
503
+ "default_member_permissions": default_member_permissions,
504
+ "nsfw": nsfw,
505
+ "name_localizations": name_localizations,
506
+ "description_localizations": description_localizations,
507
+ "integration_types": integration_types,
508
+ "contexts": contexts,
509
+ "aliases": aliases or [], # Ensure aliases is a list
510
+ "locale": locale,
511
+ }
512
+ # Filter out None values to avoid passing them as explicit None to command constructor
513
+ # Keep 'nsfw' and 'aliases' as they have defaults (False, [])
514
+ attrs = {
515
+ k: v for k, v in attrs.items() if v is not None or k in ["nsfw", "aliases"]
516
+ }
517
+ return _app_command_decorator(HybridCommand, option_meta, **attrs)
518
+
519
+
520
+ def subcommand(
521
+ parent: AppCommandGroup, *d_args: Any, **d_kwargs: Any
522
+ ) -> Callable[[Callable[..., Any]], SlashCommand]:
523
+ """Create a subcommand under an existing :class:`AppCommandGroup`."""
524
+
525
+ d_kwargs.setdefault("parent", parent)
526
+ return slash_command(*d_args, **d_kwargs)
527
+
528
+
529
+ def group(
530
+ name: str,
531
+ description: Optional[str] = None,
532
+ **kwargs: Any,
533
+ ) -> Callable[[Optional[Callable[..., Any]]], AppCommandGroup]:
534
+ """Decorator to declare a top level :class:`AppCommandGroup`."""
535
+
536
+ def decorator(func: Optional[Callable[..., Any]] = None) -> AppCommandGroup:
537
+ grp = AppCommandGroup(
538
+ name=name,
539
+ description=description,
540
+ guild_ids=kwargs.get("guild_ids"),
541
+ parent=kwargs.get("parent"),
542
+ default_member_permissions=kwargs.get("default_member_permissions"),
543
+ nsfw=kwargs.get("nsfw", False),
544
+ name_localizations=kwargs.get("name_localizations"),
545
+ description_localizations=kwargs.get("description_localizations"),
546
+ integration_types=kwargs.get("integration_types"),
547
+ contexts=kwargs.get("contexts"),
548
+ )
549
+
550
+ if func is not None:
551
+ setattr(func, "__app_command_object__", grp)
552
+ return grp
553
+
554
+ return decorator
555
+
556
+
557
+ def subcommand_group(
558
+ parent: AppCommandGroup,
559
+ name: str,
560
+ description: Optional[str] = None,
561
+ **kwargs: Any,
562
+ ) -> Callable[[Optional[Callable[..., Any]]], AppCommandGroup]:
563
+ """Create a nested :class:`AppCommandGroup` under ``parent``."""
564
+
565
+ return parent.group(
566
+ name=name,
567
+ description=description,
568
+ **kwargs,
569
+ )