disagreement 0.1.0rc1__py3-none-any.whl → 0.1.0rc3__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.
disagreement/ext/tasks.py CHANGED
@@ -18,6 +18,8 @@ class Task:
18
18
  delta: Optional[datetime.timedelta] = None,
19
19
  time_of_day: Optional[datetime.time] = None,
20
20
  on_error: Optional[Callable[[Exception], Awaitable[None]]] = None,
21
+ before_loop: Optional[Callable[[], Awaitable[None] | None]] = None,
22
+ after_loop: Optional[Callable[[], Awaitable[None] | None]] = None,
21
23
  ) -> None:
22
24
  self._coro = coro
23
25
  self._task: Optional[asyncio.Task[None]] = None
@@ -36,6 +38,8 @@ class Task:
36
38
  self._seconds = float(interval_seconds)
37
39
  self._time_of_day = time_of_day
38
40
  self._on_error = on_error
41
+ self._before_loop = before_loop
42
+ self._after_loop = after_loop
39
43
 
40
44
  def _seconds_until_time(self) -> float:
41
45
  assert self._time_of_day is not None
@@ -47,6 +51,9 @@ class Task:
47
51
 
48
52
  async def _run(self, *args: Any, **kwargs: Any) -> None:
49
53
  try:
54
+ if self._before_loop is not None:
55
+ await _maybe_call_no_args(self._before_loop)
56
+
50
57
  first = True
51
58
  while True:
52
59
  if self._time_of_day is not None:
@@ -65,6 +72,9 @@ class Task:
65
72
  first = False
66
73
  except asyncio.CancelledError:
67
74
  pass
75
+ finally:
76
+ if self._after_loop is not None:
77
+ await _maybe_call_no_args(self._after_loop)
68
78
 
69
79
  def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
70
80
  if self._task is None or self._task.done():
@@ -89,6 +99,12 @@ async def _maybe_call(
89
99
  await result
90
100
 
91
101
 
102
+ async def _maybe_call_no_args(func: Callable[[], Awaitable[None] | None]) -> None:
103
+ result = func()
104
+ if asyncio.iscoroutine(result):
105
+ await result
106
+
107
+
92
108
  class _Loop:
93
109
  def __init__(
94
110
  self,
@@ -110,6 +126,8 @@ class _Loop:
110
126
  self.on_error = on_error
111
127
  self._task: Optional[Task] = None
112
128
  self._owner: Any = None
129
+ self._before_loop: Optional[Callable[..., Awaitable[Any]]] = None
130
+ self._after_loop: Optional[Callable[..., Awaitable[Any]]] = None
113
131
 
114
132
  def __get__(self, obj: Any, objtype: Any) -> "_BoundLoop":
115
133
  return _BoundLoop(self, obj)
@@ -119,7 +137,33 @@ class _Loop:
119
137
  return self.func(*args, **kwargs)
120
138
  return self.func(self._owner, *args, **kwargs)
121
139
 
140
+ def before_loop(
141
+ self, func: Callable[..., Awaitable[Any]]
142
+ ) -> Callable[..., Awaitable[Any]]:
143
+ self._before_loop = func
144
+ return func
145
+
146
+ def after_loop(
147
+ self, func: Callable[..., Awaitable[Any]]
148
+ ) -> Callable[..., Awaitable[Any]]:
149
+ self._after_loop = func
150
+ return func
151
+
122
152
  def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
153
+ def call_before() -> Awaitable[None] | None:
154
+ if self._before_loop is None:
155
+ return None
156
+ if self._owner is not None:
157
+ return self._before_loop(self._owner)
158
+ return self._before_loop()
159
+
160
+ def call_after() -> Awaitable[None] | None:
161
+ if self._after_loop is None:
162
+ return None
163
+ if self._owner is not None:
164
+ return self._after_loop(self._owner)
165
+ return self._after_loop()
166
+
123
167
  self._task = Task(
124
168
  self._coro,
125
169
  seconds=self.seconds,
@@ -128,6 +172,8 @@ class _Loop:
128
172
  delta=self.delta,
129
173
  time_of_day=self.time_of_day,
130
174
  on_error=self.on_error,
175
+ before_loop=call_before,
176
+ after_loop=call_after,
131
177
  )
132
178
  return self._task.start(*args, **kwargs)
133
179
 
disagreement/gateway.py CHANGED
@@ -5,6 +5,7 @@ Manages the WebSocket connection to the Discord Gateway.
5
5
  """
6
6
 
7
7
  import asyncio
8
+ import logging
8
9
  import traceback
9
10
  import aiohttp
10
11
  import json
@@ -28,6 +29,9 @@ ZLIB_SUFFIX = b"\x00\x00\xff\xff"
28
29
  MAX_DECOMPRESSION_SIZE = 10 * 1024 * 1024 # 10 MiB, adjust as needed
29
30
 
30
31
 
32
+ logger = logging.getLogger(__name__)
33
+
34
+
31
35
  class GatewayClient:
32
36
  """
33
37
  Handles the Discord Gateway WebSocket connection, heartbeating, and event dispatching.
@@ -84,13 +88,17 @@ class GatewayClient:
84
88
  return
85
89
  except Exception as e: # noqa: BLE001
86
90
  if attempt >= self._max_retries - 1:
87
- print(f"Reconnect failed after {attempt + 1} attempts: {e}")
91
+ logger.error(
92
+ "Reconnect failed after %s attempts: %s", attempt + 1, e
93
+ )
88
94
  raise
89
95
  jitter = random.uniform(0, delay)
90
96
  wait_time = min(delay + jitter, self._max_backoff)
91
- print(
92
- f"Reconnect attempt {attempt + 1} failed: {e}. "
93
- f"Retrying in {wait_time:.2f} seconds..."
97
+ logger.warning(
98
+ "Reconnect attempt %s failed: %s. Retrying in %.2f seconds...",
99
+ attempt + 1,
100
+ e,
101
+ wait_time,
94
102
  )
95
103
  await asyncio.sleep(wait_time)
96
104
  delay = min(delay * 2, self._max_backoff)
@@ -112,21 +120,23 @@ class GatewayClient:
112
120
  self._buffer.clear() # Reset buffer after successful decompression
113
121
  return json.loads(decompressed.decode("utf-8"))
114
122
  except zlib.error as e:
115
- print(f"Zlib decompression error: {e}")
123
+ logger.error("Zlib decompression error: %s", e)
116
124
  self._buffer.clear() # Clear buffer on error
117
125
  self._inflator = zlib.decompressobj() # Reset inflator
118
126
  return None
119
127
  except json.JSONDecodeError as e:
120
- print(f"JSON decode error after decompression: {e}")
128
+ logger.error("JSON decode error after decompression: %s", e)
121
129
  return None
122
130
 
123
131
  async def _send_json(self, payload: Dict[str, Any]):
124
132
  if self._ws and not self._ws.closed:
125
133
  if self.verbose:
126
- print(f"GATEWAY SEND: {payload}")
134
+ logger.debug("GATEWAY SEND: %s", payload)
127
135
  await self._ws.send_json(payload)
128
136
  else:
129
- print("Gateway send attempted but WebSocket is closed or not available.")
137
+ logger.warning(
138
+ "Gateway send attempted but WebSocket is closed or not available."
139
+ )
130
140
  # raise GatewayException("WebSocket is not connected.")
131
141
 
132
142
  async def _heartbeat(self):
@@ -140,7 +150,7 @@ class GatewayClient:
140
150
  """Manages the heartbeating loop."""
141
151
  if self._heartbeat_interval is None:
142
152
  # This should not happen if HELLO was processed correctly
143
- print("Error: Heartbeat interval not set. Cannot start keep_alive.")
153
+ logger.error("Heartbeat interval not set. Cannot start keep_alive.")
144
154
  return
145
155
 
146
156
  try:
@@ -150,9 +160,9 @@ class GatewayClient:
150
160
  self._heartbeat_interval / 1000
151
161
  ) # Interval is in ms
152
162
  except asyncio.CancelledError:
153
- print("Keep_alive task cancelled.")
163
+ logger.debug("Keep_alive task cancelled.")
154
164
  except Exception as e:
155
- print(f"Error in keep_alive loop: {e}")
165
+ logger.error("Error in keep_alive loop: %s", e)
156
166
  # Potentially trigger a reconnect here or notify client
157
167
  await self._client_instance.close_gateway(code=1000) # Generic close
158
168
 
@@ -174,12 +184,12 @@ class GatewayClient:
174
184
  if self._shard_id is not None and self._shard_count is not None:
175
185
  payload["d"]["shard"] = [self._shard_id, self._shard_count]
176
186
  await self._send_json(payload)
177
- print("Sent IDENTIFY.")
187
+ logger.info("Sent IDENTIFY.")
178
188
 
179
189
  async def _resume(self):
180
190
  """Sends the RESUME payload to the Gateway."""
181
191
  if not self._session_id or self._last_sequence is None:
182
- print("Cannot RESUME: session_id or last_sequence is missing.")
192
+ logger.warning("Cannot RESUME: session_id or last_sequence is missing.")
183
193
  await self._identify() # Fallback to identify
184
194
  return
185
195
 
@@ -192,8 +202,10 @@ class GatewayClient:
192
202
  },
193
203
  }
194
204
  await self._send_json(payload)
195
- print(
196
- f"Sent RESUME for session {self._session_id} at sequence {self._last_sequence}."
205
+ logger.info(
206
+ "Sent RESUME for session %s at sequence %s.",
207
+ self._session_id,
208
+ self._last_sequence,
197
209
  )
198
210
 
199
211
  async def update_presence(
@@ -238,8 +250,9 @@ class GatewayClient:
238
250
 
239
251
  if event_name == "READY": # Special handling for READY
240
252
  if not isinstance(raw_event_d_payload, dict):
241
- print(
242
- f"Error: READY event 'd' payload is not a dict or is missing: {raw_event_d_payload}"
253
+ logger.error(
254
+ "READY event 'd' payload is not a dict or is missing: %s",
255
+ raw_event_d_payload,
243
256
  )
244
257
  # Consider raising an error or attempting a reconnect
245
258
  return
@@ -259,8 +272,8 @@ class GatewayClient:
259
272
  )
260
273
  app_id_str = str(app_id_value)
261
274
  else:
262
- print(
263
- f"Warning: Could not find application ID in READY payload. App commands may not work."
275
+ logger.warning(
276
+ "Could not find application ID in READY payload. App commands may not work."
264
277
  )
265
278
 
266
279
  # Parse and store the bot's own user object
@@ -274,20 +287,29 @@ class GatewayClient:
274
287
  raw_event_d_payload["user"]
275
288
  )
276
289
  self._client_instance.user = bot_user_obj
277
- print(
278
- f"Gateway READY. Bot User: {bot_user_obj.username}#{bot_user_obj.discriminator}. Session ID: {self._session_id}. App ID: {app_id_str}. Resume URL: {self._resume_gateway_url}"
290
+ logger.info(
291
+ "Gateway READY. Bot User: %s#%s. Session ID: %s. App ID: %s. Resume URL: %s",
292
+ bot_user_obj.username,
293
+ bot_user_obj.discriminator,
294
+ self._session_id,
295
+ app_id_str,
296
+ self._resume_gateway_url,
279
297
  )
280
298
  except Exception as e:
281
- print(f"Error parsing bot user from READY payload: {e}")
282
- print(
283
- f"Gateway READY (user parse failed). Session ID: {self._session_id}. App ID: {app_id_str}. Resume URL: {self._resume_gateway_url}"
299
+ logger.error("Error parsing bot user from READY payload: %s", e)
300
+ logger.info(
301
+ "Gateway READY (user parse failed). Session ID: %s. App ID: %s. Resume URL: %s",
302
+ self._session_id,
303
+ app_id_str,
304
+ self._resume_gateway_url,
284
305
  )
285
306
  else:
286
- print(
287
- f"Warning: Bot user object not found or invalid in READY payload."
288
- )
289
- print(
290
- f"Gateway READY (no user). Session ID: {self._session_id}. App ID: {app_id_str}. Resume URL: {self._resume_gateway_url}"
307
+ logger.warning("Bot user object not found or invalid in READY payload.")
308
+ logger.info(
309
+ "Gateway READY (no user). Session ID: %s. App ID: %s. Resume URL: %s",
310
+ self._session_id,
311
+ app_id_str,
312
+ self._resume_gateway_url,
291
313
  )
292
314
 
293
315
  await self._dispatcher.dispatch(event_name, raw_event_d_payload)
@@ -306,15 +328,16 @@ class GatewayClient:
306
328
  self._client_instance.process_interaction(interaction)
307
329
  ) # type: ignore
308
330
  else:
309
- print(
310
- "Warning: Client instance does not have process_interaction method for INTERACTION_CREATE."
331
+ logger.warning(
332
+ "Client instance does not have process_interaction method for INTERACTION_CREATE."
311
333
  )
312
334
  else:
313
- print(
314
- f"Error: INTERACTION_CREATE event 'd' payload is not a dict: {raw_event_d_payload}"
335
+ logger.error(
336
+ "INTERACTION_CREATE event 'd' payload is not a dict: %s",
337
+ raw_event_d_payload,
315
338
  )
316
339
  elif event_name == "RESUMED":
317
- print("Gateway RESUMED successfully.")
340
+ logger.info("Gateway RESUMED successfully.")
318
341
  # RESUMED 'd' payload is often an empty object or debug info.
319
342
  # Ensure it's a dict for the dispatcher.
320
343
  event_data_to_dispatch = (
@@ -330,7 +353,7 @@ class GatewayClient:
330
353
  # print(f"GATEWAY RECV EVENT: {event_name} | DATA: {event_data_to_dispatch}")
331
354
  await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
332
355
  else:
333
- print(f"Received dispatch with no event name: {data}")
356
+ logger.warning("Received dispatch with no event name: %s", data)
334
357
 
335
358
  async def _process_message(self, msg: aiohttp.WSMessage):
336
359
  """Processes a single message from the WebSocket."""
@@ -338,19 +361,20 @@ class GatewayClient:
338
361
  try:
339
362
  data = json.loads(msg.data)
340
363
  except json.JSONDecodeError:
341
- print(
342
- f"Failed to decode JSON from Gateway: {msg.data[:200]}"
343
- ) # Log snippet
364
+ logger.error("Failed to decode JSON from Gateway: %s", msg.data[:200])
344
365
  return
345
366
  elif msg.type == aiohttp.WSMsgType.BINARY:
346
367
  decompressed_data = await self._decompress_message(msg.data)
347
368
  if decompressed_data is None:
348
- print("Failed to decompress or decode binary message from Gateway.")
369
+ logger.error(
370
+ "Failed to decompress or decode binary message from Gateway."
371
+ )
349
372
  return
350
373
  data = decompressed_data
351
374
  elif msg.type == aiohttp.WSMsgType.ERROR:
352
- print(
353
- f"WebSocket error: {self._ws.exception() if self._ws else 'Unknown WSError'}"
375
+ logger.error(
376
+ "WebSocket error: %s",
377
+ self._ws.exception() if self._ws else "Unknown WSError",
354
378
  )
355
379
  raise GatewayException(
356
380
  f"WebSocket error: {self._ws.exception() if self._ws else 'Unknown WSError'}"
@@ -361,15 +385,17 @@ class GatewayClient:
361
385
  if self._ws and hasattr(self._ws, "close_code")
362
386
  else "N/A"
363
387
  )
364
- print(f"WebSocket connection closed by server. Code: {close_code}")
388
+ logger.warning(
389
+ "WebSocket connection closed by server. Code: %s", close_code
390
+ )
365
391
  # Raise an exception to signal the closure to the client's main run loop
366
392
  raise GatewayException(f"WebSocket closed by server. Code: {close_code}")
367
393
  else:
368
- print(f"Received unhandled WebSocket message type: {msg.type}")
394
+ logger.warning("Received unhandled WebSocket message type: %s", msg.type)
369
395
  return
370
396
 
371
397
  if self.verbose:
372
- print(f"GATEWAY RECV: {data}")
398
+ logger.debug("GATEWAY RECV: %s", data)
373
399
  op = data.get("op")
374
400
  # 'd' payload (event_data) is handled specifically by each opcode handler below
375
401
 
@@ -378,12 +404,16 @@ class GatewayClient:
378
404
  elif op == GatewayOpcode.HEARTBEAT: # Server requests a heartbeat
379
405
  await self._heartbeat()
380
406
  elif op == GatewayOpcode.RECONNECT: # Server requests a reconnect
381
- print("Gateway requested RECONNECT. Closing and will attempt to reconnect.")
407
+ logger.info(
408
+ "Gateway requested RECONNECT. Closing and will attempt to reconnect."
409
+ )
382
410
  await self.close(code=4000, reconnect=True)
383
411
  elif op == GatewayOpcode.INVALID_SESSION:
384
412
  # The 'd' payload for INVALID_SESSION is a boolean indicating resumability
385
413
  can_resume = data.get("d") is True
386
- print(f"Gateway indicated INVALID_SESSION. Resumable: {can_resume}")
414
+ logger.warning(
415
+ "Gateway indicated INVALID_SESSION. Resumable: %s", can_resume
416
+ )
387
417
  if not can_resume:
388
418
  self._session_id = None # Clear session_id to force re-identify
389
419
  self._last_sequence = None
@@ -395,13 +425,16 @@ class GatewayClient:
395
425
  not isinstance(hello_d_payload, dict)
396
426
  or "heartbeat_interval" not in hello_d_payload
397
427
  ):
398
- print(
399
- f"Error: HELLO event 'd' payload is invalid or missing heartbeat_interval: {hello_d_payload}"
428
+ logger.error(
429
+ "HELLO event 'd' payload is invalid or missing heartbeat_interval: %s",
430
+ hello_d_payload,
400
431
  )
401
432
  await self.close(code=1011) # Internal error, malformed HELLO
402
433
  return
403
434
  self._heartbeat_interval = hello_d_payload["heartbeat_interval"]
404
- print(f"Gateway HELLO. Heartbeat interval: {self._heartbeat_interval}ms.")
435
+ logger.info(
436
+ "Gateway HELLO. Heartbeat interval: %sms.", self._heartbeat_interval
437
+ )
405
438
  # Start heartbeating
406
439
  if self._keep_alive_task:
407
440
  self._keep_alive_task.cancel()
@@ -409,45 +442,51 @@ class GatewayClient:
409
442
 
410
443
  # Identify or Resume
411
444
  if self._session_id and self._resume_gateway_url: # Check if we can resume
412
- print("Attempting to RESUME session.")
445
+ logger.info("Attempting to RESUME session.")
413
446
  await self._resume()
414
447
  else:
415
- print("Performing initial IDENTIFY.")
448
+ logger.info("Performing initial IDENTIFY.")
416
449
  await self._identify()
417
450
  elif op == GatewayOpcode.HEARTBEAT_ACK:
418
451
  self._last_heartbeat_ack = time.monotonic()
419
452
  # print("Received heartbeat ACK.")
420
453
  pass # Good, connection is alive
421
454
  else:
422
- print(f"Received unhandled Gateway Opcode: {op} with data: {data}")
455
+ logger.warning(
456
+ "Received unhandled Gateway Opcode: %s with data: %s", op, data
457
+ )
423
458
 
424
459
  async def _receive_loop(self):
425
460
  """Continuously receives and processes messages from the WebSocket."""
426
461
  if not self._ws or self._ws.closed:
427
- print("Receive loop cannot start: WebSocket is not connected or closed.")
462
+ logger.warning(
463
+ "Receive loop cannot start: WebSocket is not connected or closed."
464
+ )
428
465
  return
429
466
 
430
467
  try:
431
468
  async for msg in self._ws:
432
469
  await self._process_message(msg)
433
470
  except asyncio.CancelledError:
434
- print("Receive_loop task cancelled.")
471
+ logger.debug("Receive_loop task cancelled.")
435
472
  except aiohttp.ClientConnectionError as e:
436
- print(f"ClientConnectionError in receive_loop: {e}. Attempting reconnect.")
473
+ logger.warning(
474
+ "ClientConnectionError in receive_loop: %s. Attempting reconnect.", e
475
+ )
437
476
  await self.close(code=1006, reconnect=True) # Abnormal closure
438
477
  except Exception as e:
439
- print(f"Unexpected error in receive_loop: {e}")
478
+ logger.error("Unexpected error in receive_loop: %s", e)
440
479
  traceback.print_exc()
441
480
  await self.close(code=1011, reconnect=True)
442
481
  finally:
443
- print("Receive_loop ended.")
482
+ logger.info("Receive_loop ended.")
444
483
  # If the loop ends unexpectedly (not due to explicit close),
445
484
  # the main client might want to try reconnecting.
446
485
 
447
486
  async def connect(self):
448
487
  """Connects to the Discord Gateway."""
449
488
  if self._ws and not self._ws.closed:
450
- print("Gateway already connected or connecting.")
489
+ logger.warning("Gateway already connected or connecting.")
451
490
  return
452
491
 
453
492
  gateway_url = (
@@ -456,14 +495,14 @@ class GatewayClient:
456
495
  if not gateway_url.endswith("?v=10&encoding=json&compress=zlib-stream"):
457
496
  gateway_url += "?v=10&encoding=json&compress=zlib-stream"
458
497
 
459
- print(f"Connecting to Gateway: {gateway_url}")
498
+ logger.info("Connecting to Gateway: %s", gateway_url)
460
499
  try:
461
500
  await self._http._ensure_session() # Ensure the HTTP client's session is active
462
501
  assert (
463
502
  self._http._session is not None
464
503
  ), "HTTPClient session not initialized after ensure_session"
465
504
  self._ws = await self._http._session.ws_connect(gateway_url, max_msg_size=0)
466
- print("Gateway WebSocket connection established.")
505
+ logger.info("Gateway WebSocket connection established.")
467
506
 
468
507
  if self._receive_task:
469
508
  self._receive_task.cancel()
@@ -488,7 +527,7 @@ class GatewayClient:
488
527
 
489
528
  async def close(self, code: int = 1000, *, reconnect: bool = False):
490
529
  """Closes the Gateway connection."""
491
- print(f"Closing Gateway connection with code {code}...")
530
+ logger.info("Closing Gateway connection with code %s...", code)
492
531
  if self._keep_alive_task and not self._keep_alive_task.done():
493
532
  self._keep_alive_task.cancel()
494
533
  try:
@@ -507,7 +546,7 @@ class GatewayClient:
507
546
 
508
547
  if self._ws and not self._ws.closed:
509
548
  await self._ws.close(code=code)
510
- print("Gateway WebSocket closed.")
549
+ logger.info("Gateway WebSocket closed.")
511
550
 
512
551
  self._ws = None
513
552
  # Do not reset session_id, last_sequence, or resume_gateway_url here
@@ -515,7 +554,7 @@ class GatewayClient:
515
554
  # The connect logic will decide whether to resume or re-identify.
516
555
  # However, if it's a non-resumable close (e.g. Invalid Session non-resumable), clear them.
517
556
  if code == 4009: # Invalid session, not resumable
518
- print("Clearing session state due to non-resumable invalid session.")
557
+ logger.info("Clearing session state due to non-resumable invalid session.")
519
558
  self._session_id = None
520
559
  self._last_sequence = None
521
560
  self._resume_gateway_url = None # This might be re-fetched anyway