disagreement 0.3.0b1__py3-none-any.whl → 0.4.0__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 (38) hide show
  1. disagreement/__init__.py +2 -4
  2. disagreement/audio.py +25 -5
  3. disagreement/cache.py +12 -3
  4. disagreement/caching.py +15 -14
  5. disagreement/client.py +86 -52
  6. disagreement/enums.py +10 -3
  7. disagreement/error_handler.py +5 -1
  8. disagreement/errors.py +1341 -3
  9. disagreement/event_dispatcher.py +1 -3
  10. disagreement/ext/__init__.py +1 -0
  11. disagreement/ext/app_commands/__init__.py +0 -2
  12. disagreement/ext/app_commands/commands.py +0 -2
  13. disagreement/ext/app_commands/context.py +0 -2
  14. disagreement/ext/app_commands/converters.py +2 -4
  15. disagreement/ext/app_commands/decorators.py +5 -7
  16. disagreement/ext/app_commands/handler.py +1 -3
  17. disagreement/ext/app_commands/hybrid.py +0 -2
  18. disagreement/ext/commands/__init__.py +0 -2
  19. disagreement/ext/commands/cog.py +0 -2
  20. disagreement/ext/commands/converters.py +16 -5
  21. disagreement/ext/commands/core.py +52 -14
  22. disagreement/ext/commands/decorators.py +3 -7
  23. disagreement/ext/commands/errors.py +0 -2
  24. disagreement/ext/commands/help.py +0 -2
  25. disagreement/ext/commands/view.py +1 -3
  26. disagreement/gateway.py +27 -25
  27. disagreement/http.py +264 -22
  28. disagreement/interactions.py +0 -2
  29. disagreement/models.py +199 -105
  30. disagreement/shard_manager.py +0 -2
  31. disagreement/ui/view.py +2 -2
  32. disagreement/voice_client.py +20 -1
  33. {disagreement-0.3.0b1.dist-info → disagreement-0.4.0.dist-info}/METADATA +32 -6
  34. disagreement-0.4.0.dist-info/RECORD +55 -0
  35. disagreement-0.3.0b1.dist-info/RECORD +0 -55
  36. {disagreement-0.3.0b1.dist-info → disagreement-0.4.0.dist-info}/WHEEL +0 -0
  37. {disagreement-0.3.0b1.dist-info → disagreement-0.4.0.dist-info}/licenses/LICENSE +0 -0
  38. {disagreement-0.3.0b1.dist-info → disagreement-0.4.0.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,3 @@
1
- # disagreement/event_dispatcher.py
2
-
3
1
  """
4
2
  Event dispatcher for handling Discord Gateway events.
5
3
  """
@@ -198,7 +196,7 @@ class EventDispatcher:
198
196
  try:
199
197
  self._listeners[event_name_upper].remove(coro)
200
198
  except ValueError:
201
- pass # Listener not in list
199
+ pass
202
200
 
203
201
  def add_waiter(
204
202
  self,
@@ -0,0 +1 @@
1
+
@@ -1,5 +1,3 @@
1
- # disagreement/ext/app_commands/__init__.py
2
-
3
1
  """
4
2
  Application Commands Extension for Disagreement.
5
3
 
@@ -1,5 +1,3 @@
1
- # disagreement/ext/app_commands/commands.py
2
-
3
1
  import inspect
4
2
  from typing import Any, Callable, Dict, List, Optional, Union, TYPE_CHECKING
5
3
 
@@ -1,5 +1,3 @@
1
- # disagreement/ext/app_commands/context.py
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  from typing import TYPE_CHECKING, Optional, List, Union, Any, Dict
@@ -1,5 +1,3 @@
1
- # disagreement/ext/app_commands/converters.py
2
-
3
1
  """
4
2
  Converters for transforming application command option values.
5
3
  """
@@ -466,8 +464,8 @@ async def run_converters(
466
464
 
467
465
  # If no specific converter, and it's not a basic type match, raise error or return raw
468
466
  # 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
467
+ if option_type in DEFAULT_CONVERTERS:
468
+ pass
471
469
 
472
470
  # If it's a model type but no converter yet, this will need to be handled
473
471
  # e.g. if param_type is User and option_type is ApplicationCommandOptionType.USER
@@ -1,5 +1,3 @@
1
- # disagreement/ext/app_commands/decorators.py
2
-
3
1
  import inspect
4
2
  import asyncio
5
3
  from dataclasses import dataclass
@@ -134,7 +132,7 @@ def _extract_options_from_signature(
134
132
  first_param = next(param_iter, None) # Consume 'self', get next
135
133
 
136
134
  if first_param and first_param.name == "ctx": # Consume 'ctx'
137
- pass # ctx is handled, now iterate over actual command options
135
+ pass
138
136
  elif (
139
137
  first_param
140
138
  ): # If first_param was not 'self' and not 'ctx', it's a command option
@@ -147,7 +145,7 @@ def _extract_options_from_signature(
147
145
  if param.kind == param.VAR_POSITIONAL or param.kind == param.VAR_KEYWORD:
148
146
  # *args and **kwargs are not directly supported by slash command options structure.
149
147
  # Could raise an error or ignore. For now, ignore.
150
- # print(f"Warning: *args/**kwargs ({param.name}) are not supported for slash command options.")
148
+
151
149
  continue
152
150
 
153
151
  option_name = param.name
@@ -190,7 +188,7 @@ def _extract_options_from_signature(
190
188
  # More complex Unions are not directly supported by a single option type.
191
189
  # Could default to STRING or raise.
192
190
  # 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.")
191
+
194
192
  actual_type_for_mapping = str
195
193
 
196
194
  elif origin is list and len(args) == 1:
@@ -198,7 +196,7 @@ def _extract_options_from_signature(
198
196
  # via repeated options or specific component interactions, not directly in slash command options.
199
197
  # This might indicate a need for a different interaction pattern or custom parsing.
200
198
  # 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]}.")
199
+
202
200
  actual_type_for_mapping = args[
203
201
  0
204
202
  ] # Use the inner type for mapping, but this is a simplification.
@@ -247,7 +245,7 @@ def _extract_options_from_signature(
247
245
 
248
246
  if not option_type:
249
247
  # 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.")
248
+
251
249
  option_type = ApplicationCommandOptionType.STRING # Default fallback
252
250
 
253
251
  required = (param.default == inspect.Parameter.empty) and not is_optional
@@ -1,5 +1,3 @@
1
- # disagreement/ext/app_commands/handler.py
2
-
3
1
  import inspect
4
2
  import json
5
3
  import logging
@@ -341,7 +339,7 @@ class AppCommandHandler:
341
339
  return value.lower() == "true"
342
340
  return bool(value)
343
341
  except (ValueError, TypeError):
344
- pass # Conversion failed
342
+ pass
345
343
  return value # Return as is if no specific resolution or conversion applied
346
344
 
347
345
  async def _resolve_value(
@@ -1,5 +1,3 @@
1
- # disagreement/ext/app_commands/hybrid.py
2
-
3
1
  from typing import Any, Callable, List, Optional
4
2
 
5
3
  from .commands import SlashCommand
@@ -1,5 +1,3 @@
1
- # disagreement/ext/commands/__init__.py
2
-
3
1
  """
4
2
  disagreement.ext.commands - A command framework extension for the Disagreement library.
5
3
  """
@@ -1,5 +1,3 @@
1
- # disagreement/ext/commands/cog.py
2
-
3
1
  import inspect
4
2
  import logging
5
3
  from typing import TYPE_CHECKING, List, Tuple, Callable, Awaitable, Any, Dict, Union
@@ -1,8 +1,9 @@
1
- # disagreement/ext/commands/converters.py
1
+ # pyright: reportIncompatibleMethodOverride=false
2
2
 
3
3
  from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, Generic
4
4
  from abc import ABC, abstractmethod
5
5
  import re
6
+ import inspect
6
7
 
7
8
  from .errors import BadArgument
8
9
  from disagreement.models import Member, Guild, Role
@@ -36,6 +37,20 @@ class Converter(ABC, Generic[T]):
36
37
  raise NotImplementedError("Converter subclass must implement convert method.")
37
38
 
38
39
 
40
+ class Greedy(list):
41
+ """Type hint helper to greedily consume arguments."""
42
+
43
+ converter: Any = None
44
+
45
+ def __class_getitem__(cls, param: Any) -> type: # pyright: ignore[override]
46
+ if isinstance(param, tuple):
47
+ if len(param) != 1:
48
+ raise TypeError("Greedy[...] expects a single parameter")
49
+ param = param[0]
50
+ name = f"Greedy[{getattr(param, '__name__', str(param))}]"
51
+ return type(name, (Greedy,), {"converter": param})
52
+
53
+
39
54
  # --- Built-in Type Converters ---
40
55
 
41
56
 
@@ -169,7 +184,3 @@ async def run_converters(ctx: "CommandContext", annotation: Any, argument: str)
169
184
  raise BadArgument(f"No converter found for type annotation '{annotation}'.")
170
185
 
171
186
  return argument # Default to string if no annotation or annotation is str
172
-
173
-
174
- # Need to import inspect for the run_converters function
175
- import inspect
@@ -1,5 +1,3 @@
1
- # disagreement/ext/commands/core.py
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import asyncio
@@ -29,7 +27,7 @@ from .errors import (
29
27
  CheckFailure,
30
28
  CommandInvokeError,
31
29
  )
32
- from .converters import run_converters, DEFAULT_CONVERTERS, Converter
30
+ from .converters import Greedy, run_converters, DEFAULT_CONVERTERS, Converter
33
31
  from disagreement.typing import Typing
34
32
 
35
33
  logger = logging.getLogger(__name__)
@@ -46,29 +44,39 @@ class GroupMixin:
46
44
  self.commands: Dict[str, "Command"] = {}
47
45
  self.name: str = ""
48
46
 
49
- def command(self, **attrs: Any) -> Callable[[Callable[..., Awaitable[None]]], "Command"]:
47
+ def command(
48
+ self, **attrs: Any
49
+ ) -> Callable[[Callable[..., Awaitable[None]]], "Command"]:
50
50
  def decorator(func: Callable[..., Awaitable[None]]) -> "Command":
51
51
  cmd = Command(func, **attrs)
52
52
  cmd.cog = getattr(self, "cog", None)
53
53
  self.add_command(cmd)
54
54
  return cmd
55
+
55
56
  return decorator
56
57
 
57
- def group(self, **attrs: Any) -> Callable[[Callable[..., Awaitable[None]]], "Group"]:
58
+ def group(
59
+ self, **attrs: Any
60
+ ) -> Callable[[Callable[..., Awaitable[None]]], "Group"]:
58
61
  def decorator(func: Callable[..., Awaitable[None]]) -> "Group":
59
62
  cmd = Group(func, **attrs)
60
63
  cmd.cog = getattr(self, "cog", None)
61
64
  self.add_command(cmd)
62
65
  return cmd
66
+
63
67
  return decorator
64
68
 
65
69
  def add_command(self, command: "Command") -> None:
66
70
  if command.name in self.commands:
67
- raise ValueError(f"Command '{command.name}' is already registered in group '{self.name}'.")
71
+ raise ValueError(
72
+ f"Command '{command.name}' is already registered in group '{self.name}'."
73
+ )
68
74
  self.commands[command.name.lower()] = command
69
75
  for alias in command.aliases:
70
76
  if alias in self.commands:
71
- logger.warning(f"Alias '{alias}' for command '{command.name}' in group '{self.name}' conflicts with an existing command or alias.")
77
+ logger.warning(
78
+ f"Alias '{alias}' for command '{command.name}' in group '{self.name}' conflicts with an existing command or alias."
79
+ )
72
80
  self.commands[alias.lower()] = command
73
81
 
74
82
  def get_command(self, name: str) -> Optional["Command"]:
@@ -181,6 +189,7 @@ class Command(GroupMixin):
181
189
 
182
190
  class Group(Command):
183
191
  """A command that can have subcommands."""
192
+
184
193
  def __init__(self, callback: Callable[..., Awaitable[None]], **attrs: Any):
185
194
  super().__init__(callback, **attrs)
186
195
 
@@ -494,7 +503,34 @@ class CommandHandler:
494
503
  None # Holds the raw string for current param
495
504
  )
496
505
 
497
- if view.eof: # No more input string
506
+ annotation = param.annotation
507
+ if inspect.isclass(annotation) and issubclass(annotation, Greedy):
508
+ greedy_values = []
509
+ converter_type = annotation.converter
510
+ while not view.eof:
511
+ view.skip_whitespace()
512
+ if view.eof:
513
+ break
514
+ start = view.index
515
+ if view.buffer[view.index] == '"':
516
+ arg_str_value = view.get_quoted_string()
517
+ if arg_str_value == "" and view.buffer[view.index] == '"':
518
+ raise BadArgument(
519
+ f"Unterminated quoted string for argument '{param.name}'."
520
+ )
521
+ else:
522
+ arg_str_value = view.get_word()
523
+ try:
524
+ converted = await run_converters(
525
+ ctx, converter_type, arg_str_value
526
+ )
527
+ except BadArgument:
528
+ view.index = start
529
+ break
530
+ greedy_values.append(converted)
531
+ final_value_for_param = greedy_values
532
+ arg_str_value = None
533
+ elif view.eof: # No more input string
498
534
  if param.default is not inspect.Parameter.empty:
499
535
  final_value_for_param = param.default
500
536
  elif param.kind != inspect.Parameter.VAR_KEYWORD:
@@ -529,9 +565,7 @@ class CommandHandler:
529
565
 
530
566
  # If final_value_for_param was not set by greedy logic, try conversion
531
567
  if final_value_for_param is inspect.Parameter.empty:
532
- if (
533
- arg_str_value is None
534
- ): # Should not happen if view.get_word/get_quoted_string is robust
568
+ if arg_str_value is None:
535
569
  if param.default is not inspect.Parameter.empty:
536
570
  final_value_for_param = param.default
537
571
  else:
@@ -571,7 +605,7 @@ class CommandHandler:
571
605
  final_value_for_param = None
572
606
  elif last_err_union:
573
607
  raise last_err_union
574
- else: # Should not be reached if logic is correct
608
+ else:
575
609
  raise BadArgument(
576
610
  f"Could not convert '{arg_str_value}' to any of {union_args} for param '{param.name}'."
577
611
  )
@@ -656,7 +690,9 @@ class CommandHandler:
656
690
  elif command.invoke_without_command:
657
691
  view.index -= len(potential_subcommand) + view.previous
658
692
  else:
659
- raise CommandNotFound(f"Subcommand '{potential_subcommand}' not found.")
693
+ raise CommandNotFound(
694
+ f"Subcommand '{potential_subcommand}' not found."
695
+ )
660
696
 
661
697
  ctx = CommandContext(
662
698
  message=message,
@@ -681,7 +717,9 @@ class CommandHandler:
681
717
  if hasattr(self.client, "on_command_error"):
682
718
  await self.client.on_command_error(ctx, e)
683
719
  except Exception as e:
684
- logger.error("Unexpected error invoking command '%s': %s", original_command.name, e)
720
+ logger.error(
721
+ "Unexpected error invoking command '%s': %s", original_command.name, e
722
+ )
685
723
  exc = CommandInvokeError(e)
686
724
  if hasattr(self.client, "on_command_error"):
687
725
  await self.client.on_command_error(ctx, exc)
@@ -1,4 +1,3 @@
1
- # disagreement/ext/commands/decorators.py
2
1
  from __future__ import annotations
3
2
 
4
3
  import asyncio
@@ -218,6 +217,7 @@ def requires_permissions(
218
217
 
219
218
  return check(predicate)
220
219
 
220
+
221
221
  def has_role(
222
222
  name_or_id: str | int,
223
223
  ) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
@@ -241,9 +241,7 @@ def has_role(
241
241
  raise CheckFailure("Could not resolve author to a guild member.")
242
242
 
243
243
  # Create a list of the member's role objects by looking them up in the guild's roles list
244
- member_roles = [
245
- role for role in ctx.guild.roles if role.id in author.roles
246
- ]
244
+ member_roles = [role for role in ctx.guild.roles if role.id in author.roles]
247
245
 
248
246
  if any(
249
247
  role.id == str(name_or_id) or role.name == name_or_id
@@ -278,9 +276,7 @@ def has_any_role(
278
276
  if not author:
279
277
  raise CheckFailure("Could not resolve author to a guild member.")
280
278
 
281
- member_roles = [
282
- role for role in ctx.guild.roles if role.id in author.roles
283
- ]
279
+ member_roles = [role for role in ctx.guild.roles if role.id in author.roles]
284
280
  # Convert names_or_ids to a set for efficient lookup
285
281
  names_or_ids_set = set(map(str, names_or_ids))
286
282
 
@@ -1,5 +1,3 @@
1
- # disagreement/ext/commands/errors.py
2
-
3
1
  """
4
2
  Custom exceptions for the command extension.
5
3
  """
@@ -1,5 +1,3 @@
1
- # disagreement/ext/commands/help.py
2
-
3
1
  from typing import List, Optional
4
2
 
5
3
  from .core import Command, CommandContext, CommandHandler
@@ -1,5 +1,3 @@
1
- # disagreement/ext/commands/view.py
2
-
3
1
  import re
4
2
 
5
3
 
@@ -47,7 +45,7 @@ class StringView:
47
45
  word = match.group(0)
48
46
  self.index += len(word)
49
47
  return word
50
- return "" # Should not happen if not eof and skip_whitespace was called
48
+ return ""
51
49
 
52
50
  def get_quoted_string(self) -> str:
53
51
  """
disagreement/gateway.py CHANGED
@@ -1,5 +1,3 @@
1
- # disagreement/gateway.py
2
-
3
1
  """
4
2
  Manages the WebSocket connection to the Discord Gateway.
5
3
  """
@@ -14,6 +12,8 @@ import time
14
12
  import random
15
13
  from typing import Optional, TYPE_CHECKING, Any, Dict
16
14
 
15
+ from .models import Activity
16
+
17
17
  from .enums import GatewayOpcode, GatewayIntent
18
18
  from .errors import GatewayException, DisagreementException, AuthenticationError
19
19
  from .interactions import Interaction
@@ -63,7 +63,11 @@ class GatewayClient:
63
63
  self._max_backoff: float = max_backoff
64
64
 
65
65
  self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
66
- self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
66
+ try:
67
+ self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
68
+ except RuntimeError:
69
+ self._loop = asyncio.new_event_loop()
70
+ asyncio.set_event_loop(self._loop)
67
71
  self._heartbeat_interval: Optional[float] = None
68
72
  self._last_sequence: Optional[int] = None
69
73
  self._session_id: Optional[str] = None
@@ -146,12 +150,11 @@ class GatewayClient:
146
150
  self._last_heartbeat_sent = time.monotonic()
147
151
  payload = {"op": GatewayOpcode.HEARTBEAT, "d": self._last_sequence}
148
152
  await self._send_json(payload)
149
- # print("Sent heartbeat.")
150
153
 
151
154
  async def _keep_alive(self):
152
155
  """Manages the heartbeating loop."""
153
156
  if self._heartbeat_interval is None:
154
- # This should not happen if HELLO was processed correctly
157
+
155
158
  logger.error("Heartbeat interval not set. Cannot start keep_alive.")
156
159
  return
157
160
 
@@ -213,26 +216,17 @@ class GatewayClient:
213
216
  async def update_presence(
214
217
  self,
215
218
  status: str,
216
- activity_name: Optional[str] = None,
217
- activity_type: int = 0,
219
+ activity: Optional[Activity] = None,
220
+ *,
218
221
  since: int = 0,
219
222
  afk: bool = False,
220
- ):
223
+ ) -> None:
221
224
  """Sends the presence update payload to the Gateway."""
222
225
  payload = {
223
226
  "op": GatewayOpcode.PRESENCE_UPDATE,
224
227
  "d": {
225
228
  "since": since,
226
- "activities": (
227
- [
228
- {
229
- "name": activity_name,
230
- "type": activity_type,
231
- }
232
- ]
233
- if activity_name
234
- else []
235
- ),
229
+ "activities": [activity.to_dict()] if activity else [],
236
230
  "status": status,
237
231
  "afk": afk,
238
232
  },
@@ -353,12 +347,15 @@ class GatewayClient:
353
347
  future._members.extend(raw_event_d_payload.get("members", [])) # type: ignore
354
348
 
355
349
  # If this is the last chunk, resolve the future
356
- if raw_event_d_payload.get("chunk_index") == raw_event_d_payload.get("chunk_count", 1) - 1:
350
+ if (
351
+ raw_event_d_payload.get("chunk_index")
352
+ == raw_event_d_payload.get("chunk_count", 1) - 1
353
+ ):
357
354
  future.set_result(future._members) # type: ignore
358
355
  del self._member_chunk_requests[nonce]
359
356
 
360
357
  elif event_name == "INTERACTION_CREATE":
361
- # print(f"GATEWAY RECV INTERACTION_CREATE: {raw_event_d_payload}")
358
+
362
359
  if isinstance(raw_event_d_payload, dict):
363
360
  interaction = Interaction(
364
361
  data=raw_event_d_payload, client_instance=self._client_instance
@@ -397,7 +394,7 @@ class GatewayClient:
397
394
  event_data_to_dispatch = (
398
395
  raw_event_d_payload if isinstance(raw_event_d_payload, dict) else {}
399
396
  )
400
- # print(f"GATEWAY RECV EVENT: {event_name} | DATA: {event_data_to_dispatch}")
397
+
401
398
  await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
402
399
  else:
403
400
  logger.warning("Received dispatch with no event name: %s", data)
@@ -496,8 +493,6 @@ class GatewayClient:
496
493
  await self._identify()
497
494
  elif op == GatewayOpcode.HEARTBEAT_ACK:
498
495
  self._last_heartbeat_ack = time.monotonic()
499
- # print("Received heartbeat ACK.")
500
- pass # Good, connection is alive
501
496
  else:
502
497
  logger.warning(
503
498
  "Received unhandled Gateway Opcode: %s with data: %s", op, data
@@ -584,7 +579,7 @@ class GatewayClient:
584
579
  try:
585
580
  await self._keep_alive_task
586
581
  except asyncio.CancelledError:
587
- pass # Expected
582
+ pass
588
583
 
589
584
  if self._receive_task and not self._receive_task.done():
590
585
  current = asyncio.current_task(loop=self._loop)
@@ -593,7 +588,7 @@ class GatewayClient:
593
588
  try:
594
589
  await self._receive_task
595
590
  except asyncio.CancelledError:
596
- pass # Expected
591
+ pass
597
592
 
598
593
  if self._ws and not self._ws.closed:
599
594
  await self._ws.close(code=code)
@@ -621,6 +616,13 @@ class GatewayClient:
621
616
  return None
622
617
  return self._last_heartbeat_ack - self._last_heartbeat_sent
623
618
 
619
+ @property
620
+ def latency_ms(self) -> Optional[float]:
621
+ """Returns the latency between heartbeat and ACK in milliseconds."""
622
+ if self._last_heartbeat_sent is None or self._last_heartbeat_ack is None:
623
+ return None
624
+ return (self._last_heartbeat_ack - self._last_heartbeat_sent) * 1000
625
+
624
626
  @property
625
627
  def last_heartbeat_sent(self) -> Optional[float]:
626
628
  return self._last_heartbeat_sent