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/http.py CHANGED
@@ -5,6 +5,7 @@ HTTP client for interacting with the Discord REST API.
5
5
  """
6
6
 
7
7
  import asyncio
8
+ import logging
8
9
  import aiohttp # pylint: disable=import-error
9
10
  import json
10
11
  from urllib.parse import quote
@@ -17,15 +18,19 @@ from .errors import (
17
18
  DisagreementException,
18
19
  )
19
20
  from . import __version__ # For User-Agent
21
+ from .rate_limiter import RateLimiter
22
+ from .interactions import InteractionResponsePayload
20
23
 
21
24
  if TYPE_CHECKING:
22
25
  from .client import Client
23
26
  from .models import Message, Webhook, File
24
- from .interactions import ApplicationCommand, InteractionResponsePayload, Snowflake
27
+ from .interactions import ApplicationCommand, Snowflake
25
28
 
26
29
  # Discord API constants
27
30
  API_BASE_URL = "https://discord.com/api/v10" # Using API v10
28
31
 
32
+ logger = logging.getLogger(__name__)
33
+
29
34
 
30
35
  class HTTPClient:
31
36
  """Handles HTTP requests to the Discord API."""
@@ -44,8 +49,7 @@ class HTTPClient:
44
49
 
45
50
  self.verbose = verbose
46
51
 
47
- self._global_rate_limit_lock = asyncio.Event()
48
- self._global_rate_limit_lock.set() # Initially unlocked
52
+ self._rate_limiter = RateLimiter()
49
53
 
50
54
  async def _ensure_session(self):
51
55
  if self._session is None or self._session.closed:
@@ -85,12 +89,18 @@ class HTTPClient:
85
89
  final_headers.update(custom_headers)
86
90
 
87
91
  if self.verbose:
88
- print(f"HTTP REQUEST: {method} {url} | payload={payload} params={params}")
92
+ logger.debug(
93
+ "HTTP REQUEST: %s %s | payload=%s params=%s",
94
+ method,
95
+ url,
96
+ payload,
97
+ params,
98
+ )
89
99
 
90
- # Global rate limit handling
91
- await self._global_rate_limit_lock.wait()
100
+ route = f"{method.upper()}:{endpoint}"
92
101
 
93
102
  for attempt in range(5): # Max 5 retries for rate limits
103
+ await self._rate_limiter.acquire(route)
94
104
  assert self._session is not None, "ClientSession not initialized"
95
105
  async with self._session.request(
96
106
  method,
@@ -118,7 +128,11 @@ class HTTPClient:
118
128
  ) # Fallback to text if JSON parsing fails
119
129
 
120
130
  if self.verbose:
121
- print(f"HTTP RESPONSE: {response.status} {url} | {data}")
131
+ logger.debug(
132
+ "HTTP RESPONSE: %s %s | %s", response.status, url, data
133
+ )
134
+
135
+ self._rate_limiter.release(route, response.headers)
122
136
 
123
137
  if 200 <= response.status < 300:
124
138
  if response.status == 204:
@@ -142,16 +156,17 @@ class HTTPClient:
142
156
  if data and isinstance(data, dict) and "message" in data:
143
157
  error_message += f" Discord says: {data['message']}"
144
158
 
145
- if is_global:
146
- self._global_rate_limit_lock.clear()
147
- await asyncio.sleep(retry_after)
148
- self._global_rate_limit_lock.set()
149
- else:
150
- await asyncio.sleep(retry_after)
159
+ await self._rate_limiter.handle_rate_limit(
160
+ route, retry_after, is_global
161
+ )
151
162
 
152
163
  if attempt < 4: # Don't log on the last attempt before raising
153
- print(
154
- f"{error_message} Retrying after {retry_after}s (Attempt {attempt + 1}/5). Global: {is_global}"
164
+ logger.warning(
165
+ "%s Retrying after %ss (Attempt %s/5). Global: %s",
166
+ error_message,
167
+ retry_after,
168
+ attempt + 1,
169
+ is_global,
155
170
  )
156
171
  continue # Retry the request
157
172
  else: # Last attempt failed
@@ -348,6 +363,16 @@ class HTTPClient:
348
363
  f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
349
364
  )
350
365
 
366
+ async def clear_reactions(
367
+ self, channel_id: "Snowflake", message_id: "Snowflake"
368
+ ) -> None:
369
+ """Removes all reactions from a message."""
370
+
371
+ await self.request(
372
+ "DELETE",
373
+ f"/channels/{channel_id}/messages/{message_id}/reactions",
374
+ )
375
+
351
376
  async def bulk_delete_messages(
352
377
  self, channel_id: "Snowflake", messages: List["Snowflake"]
353
378
  ) -> List["Snowflake"]:
@@ -411,6 +436,92 @@ class HTTPClient:
411
436
 
412
437
  await self.request("DELETE", f"/webhooks/{webhook_id}")
413
438
 
439
+ async def execute_webhook(
440
+ self,
441
+ webhook_id: "Snowflake",
442
+ token: str,
443
+ *,
444
+ content: Optional[str] = None,
445
+ tts: bool = False,
446
+ embeds: Optional[List[Dict[str, Any]]] = None,
447
+ components: Optional[List[Dict[str, Any]]] = None,
448
+ allowed_mentions: Optional[dict] = None,
449
+ attachments: Optional[List[Any]] = None,
450
+ files: Optional[List[Any]] = None,
451
+ flags: Optional[int] = None,
452
+ username: Optional[str] = None,
453
+ avatar_url: Optional[str] = None,
454
+ ) -> Dict[str, Any]:
455
+ """Executes a webhook and returns the created message."""
456
+
457
+ payload: Dict[str, Any] = {}
458
+ if content is not None:
459
+ payload["content"] = content
460
+ if tts:
461
+ payload["tts"] = True
462
+ if embeds:
463
+ payload["embeds"] = embeds
464
+ if components:
465
+ payload["components"] = components
466
+ if allowed_mentions:
467
+ payload["allowed_mentions"] = allowed_mentions
468
+ if username:
469
+ payload["username"] = username
470
+ if avatar_url:
471
+ payload["avatar_url"] = avatar_url
472
+
473
+ all_files: List["File"] = []
474
+ if attachments is not None:
475
+ payload["attachments"] = []
476
+ for a in attachments:
477
+ if hasattr(a, "data") and hasattr(a, "filename"):
478
+ idx = len(all_files)
479
+ all_files.append(a)
480
+ payload["attachments"].append({"id": idx, "filename": a.filename})
481
+ else:
482
+ payload["attachments"].append(
483
+ a.to_dict() if hasattr(a, "to_dict") else a
484
+ )
485
+ if files is not None:
486
+ for f in files:
487
+ if hasattr(f, "data") and hasattr(f, "filename"):
488
+ idx = len(all_files)
489
+ all_files.append(f)
490
+ if "attachments" not in payload:
491
+ payload["attachments"] = []
492
+ payload["attachments"].append({"id": idx, "filename": f.filename})
493
+ else:
494
+ raise TypeError("files must be File objects")
495
+ if flags:
496
+ payload["flags"] = flags
497
+
498
+ if all_files:
499
+ form = aiohttp.FormData()
500
+ form.add_field(
501
+ "payload_json", json.dumps(payload), content_type="application/json"
502
+ )
503
+ for idx, f in enumerate(all_files):
504
+ form.add_field(
505
+ f"files[{idx}]",
506
+ f.data,
507
+ filename=f.filename,
508
+ content_type="application/octet-stream",
509
+ )
510
+ return await self.request(
511
+ "POST",
512
+ f"/webhooks/{webhook_id}/{token}",
513
+ payload=form,
514
+ is_json=False,
515
+ use_auth_header=False,
516
+ )
517
+
518
+ return await self.request(
519
+ "POST",
520
+ f"/webhooks/{webhook_id}/{token}",
521
+ payload=payload,
522
+ use_auth_header=False,
523
+ )
524
+
414
525
  async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
415
526
  """Fetches a user object for a given user ID."""
416
527
  return await self.request("GET", f"/users/{user_id}")
@@ -657,7 +768,7 @@ class HTTPClient:
657
768
  self,
658
769
  interaction_id: "Snowflake",
659
770
  interaction_token: str,
660
- payload: "InteractionResponsePayload",
771
+ payload: Union["InteractionResponsePayload", Dict[str, Any]],
661
772
  *,
662
773
  ephemeral: bool = False,
663
774
  ) -> None:
@@ -670,10 +781,16 @@ class HTTPClient:
670
781
  """
671
782
  # Interaction responses do not use the bot token in the Authorization header.
672
783
  # They are authenticated by the interaction_token in the URL.
784
+ payload_data: Dict[str, Any]
785
+ if isinstance(payload, InteractionResponsePayload):
786
+ payload_data = payload.to_dict()
787
+ else:
788
+ payload_data = payload
789
+
673
790
  await self.request(
674
791
  "POST",
675
792
  f"/interactions/{interaction_id}/{interaction_token}/callback",
676
- payload=payload.to_dict(),
793
+ payload=payload_data,
677
794
  use_auth_header=False,
678
795
  )
679
796
 
@@ -395,17 +395,14 @@ class Interaction:
395
395
 
396
396
  async def respond_modal(self, modal: "Modal") -> None:
397
397
  """|coro| Send a modal in response to this interaction."""
398
-
399
- from typing import Any, cast
400
-
401
- payload = {
402
- "type": InteractionCallbackType.MODAL.value,
403
- "data": modal.to_dict(),
404
- }
398
+ payload = InteractionResponsePayload(
399
+ type=InteractionCallbackType.MODAL,
400
+ data=modal.to_dict(),
401
+ )
405
402
  await self._client._http.create_interaction_response(
406
403
  interaction_id=self.id,
407
404
  interaction_token=self.token,
408
- payload=cast(Any, payload),
405
+ payload=payload,
409
406
  )
410
407
 
411
408
  async def edit(
@@ -489,7 +486,7 @@ class InteractionResponse:
489
486
  """Sends a modal response."""
490
487
  payload = InteractionResponsePayload(
491
488
  type=InteractionCallbackType.MODAL,
492
- data=InteractionCallbackData(modal.to_dict()),
489
+ data=modal.to_dict(),
493
490
  )
494
491
  await self._interaction._client._http.create_interaction_response(
495
492
  self._interaction.id,
@@ -510,7 +507,7 @@ class InteractionCallbackData:
510
507
  )
511
508
  self.allowed_mentions: Optional[AllowedMentions] = (
512
509
  AllowedMentions(data["allowed_mentions"])
513
- if data.get("allowed_mentions")
510
+ if "allowed_mentions" in data
514
511
  else None
515
512
  )
516
513
  self.flags: Optional[int] = data.get("flags") # MessageFlags enum could be used
@@ -557,16 +554,22 @@ class InteractionResponsePayload:
557
554
  def __init__(
558
555
  self,
559
556
  type: InteractionCallbackType,
560
- data: Optional[InteractionCallbackData] = None,
557
+ data: Optional[Union[InteractionCallbackData, Dict[str, Any]]] = None,
561
558
  ):
562
- self.type: InteractionCallbackType = type
563
- self.data: Optional[InteractionCallbackData] = data
559
+ self.type = type
560
+ self.data = data
564
561
 
565
562
  def to_dict(self) -> Dict[str, Any]:
566
563
  payload: Dict[str, Any] = {"type": self.type.value}
567
564
  if self.data:
568
- payload["data"] = self.data.to_dict()
565
+ if isinstance(self.data, dict):
566
+ payload["data"] = self.data
567
+ else:
568
+ payload["data"] = self.data.to_dict()
569
569
  return payload
570
570
 
571
571
  def __repr__(self) -> str:
572
572
  return f"<InteractionResponsePayload type={self.type!r}>"
573
+
574
+ def __getitem__(self, key: str) -> Any:
575
+ return self.to_dict()[key]
disagreement/models.py CHANGED
@@ -4,12 +4,12 @@
4
4
  Data models for Discord objects.
5
5
  """
6
6
 
7
- import json
8
- import asyncio
9
- import aiohttp # pylint: disable=import-error
10
7
  import asyncio
11
- from typing import Optional, TYPE_CHECKING, List, Dict, Any, Union
8
+ import json
9
+ from typing import Any, AsyncIterator, Dict, List, Optional, TYPE_CHECKING, Union
12
10
 
11
+ import aiohttp # pylint: disable=import-error
12
+ from .color import Color
13
13
  from .errors import DisagreementException, HTTPException
14
14
  from .enums import ( # These enums will need to be defined in disagreement/enums.py
15
15
  VerificationLevel,
@@ -201,6 +201,21 @@ class Message:
201
201
  view=view,
202
202
  )
203
203
 
204
+ async def add_reaction(self, emoji: str) -> None:
205
+ """|coro| Add a reaction to this message."""
206
+
207
+ await self._client.add_reaction(self.channel_id, self.id, emoji)
208
+
209
+ async def remove_reaction(self, emoji: str) -> None:
210
+ """|coro| Remove the bot's reaction from this message."""
211
+
212
+ await self._client.remove_reaction(self.channel_id, self.id, emoji)
213
+
214
+ async def clear_reactions(self) -> None:
215
+ """|coro| Remove all reactions from this message."""
216
+
217
+ await self._client.clear_reactions(self.channel_id, self.id)
218
+
204
219
  async def delete(self, delay: Optional[float] = None) -> None:
205
220
  """|coro|
206
221
 
@@ -312,7 +327,7 @@ class Embed:
312
327
  self.description: Optional[str] = data.get("description")
313
328
  self.url: Optional[str] = data.get("url")
314
329
  self.timestamp: Optional[str] = data.get("timestamp") # ISO8601 timestamp
315
- self.color: Optional[int] = data.get("color")
330
+ self.color = Color.parse(data.get("color"))
316
331
 
317
332
  self.footer: Optional[EmbedFooter] = (
318
333
  EmbedFooter(data["footer"]) if data.get("footer") else None
@@ -342,7 +357,7 @@ class Embed:
342
357
  if self.timestamp:
343
358
  payload["timestamp"] = self.timestamp
344
359
  if self.color is not None:
345
- payload["color"] = self.color
360
+ payload["color"] = self.color.value
346
361
  if self.footer:
347
362
  payload["footer"] = self.footer.to_dict()
348
363
  if self.image:
@@ -528,6 +543,12 @@ class Member(User): # Member inherits from User
528
543
  def __repr__(self) -> str:
529
544
  return f"<Member id='{self.id}' username='{self.username}' nick='{self.nick}'>"
530
545
 
546
+ @property
547
+ def display_name(self) -> str:
548
+ """Return the nickname if set, otherwise the username."""
549
+
550
+ return self.nick or self.username
551
+
531
552
  async def kick(self, *, reason: Optional[str] = None) -> None:
532
553
  if not self.guild_id or not self._client:
533
554
  raise DisagreementException("Member.kick requires guild_id and client")
@@ -1065,6 +1086,19 @@ class TextChannel(Channel):
1065
1086
  )
1066
1087
  self.last_pin_timestamp: Optional[str] = data.get("last_pin_timestamp")
1067
1088
 
1089
+ def history(
1090
+ self,
1091
+ *,
1092
+ limit: Optional[int] = None,
1093
+ before: Optional[str] = None,
1094
+ after: Optional[str] = None,
1095
+ ) -> AsyncIterator["Message"]:
1096
+ """Return an async iterator over this channel's messages."""
1097
+
1098
+ from .utils import message_pager
1099
+
1100
+ return message_pager(self, limit=limit, before=before, after=after)
1101
+
1068
1102
  async def send(
1069
1103
  self,
1070
1104
  content: Optional[str] = None,
@@ -1224,6 +1258,36 @@ class DMChannel(Channel):
1224
1258
  components=components,
1225
1259
  )
1226
1260
 
1261
+ async def history(
1262
+ self,
1263
+ *,
1264
+ limit: Optional[int] = 100,
1265
+ before: "Snowflake | None" = None,
1266
+ ):
1267
+ """An async iterator over messages in this DM."""
1268
+
1269
+ params: Dict[str, Union[int, str]] = {}
1270
+ if before is not None:
1271
+ params["before"] = before
1272
+
1273
+ fetched = 0
1274
+ while True:
1275
+ to_fetch = 100 if limit is None else min(100, limit - fetched)
1276
+ if to_fetch <= 0:
1277
+ break
1278
+ params["limit"] = to_fetch
1279
+ messages = await self._client._http.request(
1280
+ "GET", f"/channels/{self.id}/messages", params=params.copy()
1281
+ )
1282
+ if not messages:
1283
+ break
1284
+ params["before"] = messages[-1]["id"]
1285
+ for msg in messages:
1286
+ yield Message(msg, self._client)
1287
+ fetched += 1
1288
+ if limit is not None and fetched >= limit:
1289
+ return
1290
+
1227
1291
  def __repr__(self) -> str:
1228
1292
  recipient_repr = self.recipient.username if self.recipient else "Unknown"
1229
1293
  return f"<DMChannel id='{self.id}' recipient='{recipient_repr}'>"
@@ -1317,6 +1381,57 @@ class Webhook:
1317
1381
 
1318
1382
  return cls({"id": webhook_id, "token": token, "url": url})
1319
1383
 
1384
+ async def send(
1385
+ self,
1386
+ content: Optional[str] = None,
1387
+ *,
1388
+ username: Optional[str] = None,
1389
+ avatar_url: Optional[str] = None,
1390
+ tts: bool = False,
1391
+ embed: Optional["Embed"] = None,
1392
+ embeds: Optional[List["Embed"]] = None,
1393
+ components: Optional[List["ActionRow"]] = None,
1394
+ allowed_mentions: Optional[Dict[str, Any]] = None,
1395
+ attachments: Optional[List[Any]] = None,
1396
+ files: Optional[List[Any]] = None,
1397
+ flags: Optional[int] = None,
1398
+ ) -> "Message":
1399
+ """Send a message using this webhook."""
1400
+
1401
+ if not self._client:
1402
+ raise DisagreementException("Webhook is not bound to a Client")
1403
+ assert self.token is not None, "Webhook token missing"
1404
+
1405
+ if embed and embeds:
1406
+ raise ValueError("Cannot provide both embed and embeds.")
1407
+
1408
+ final_embeds_payload: Optional[List[Dict[str, Any]]] = None
1409
+ if embed:
1410
+ final_embeds_payload = [embed.to_dict()]
1411
+ elif embeds:
1412
+ final_embeds_payload = [e.to_dict() for e in embeds]
1413
+
1414
+ components_payload: Optional[List[Dict[str, Any]]] = None
1415
+ if components:
1416
+ components_payload = [c.to_dict() for c in components]
1417
+
1418
+ message_data = await self._client._http.execute_webhook(
1419
+ self.id,
1420
+ self.token,
1421
+ content=content,
1422
+ tts=tts,
1423
+ embeds=final_embeds_payload,
1424
+ components=components_payload,
1425
+ allowed_mentions=allowed_mentions,
1426
+ attachments=attachments,
1427
+ files=files,
1428
+ flags=flags,
1429
+ username=username,
1430
+ avatar_url=avatar_url,
1431
+ )
1432
+
1433
+ return self._client.parse_message(message_data)
1434
+
1320
1435
 
1321
1436
  # --- Message Components ---
1322
1437
 
@@ -1708,13 +1823,13 @@ class Container(Component):
1708
1823
  def __init__(
1709
1824
  self,
1710
1825
  components: List[Component],
1711
- accent_color: Optional[int] = None,
1826
+ accent_color: Color | int | str | None = None,
1712
1827
  spoiler: bool = False,
1713
1828
  id: Optional[int] = None,
1714
1829
  ):
1715
1830
  super().__init__(ComponentType.CONTAINER)
1716
1831
  self.components = components
1717
- self.accent_color = accent_color
1832
+ self.accent_color = Color.parse(accent_color)
1718
1833
  self.spoiler = spoiler
1719
1834
  self.id = id
1720
1835
 
@@ -1722,7 +1837,7 @@ class Container(Component):
1722
1837
  payload = super().to_dict()
1723
1838
  payload["components"] = [c.to_dict() for c in self.components]
1724
1839
  if self.accent_color:
1725
- payload["accent_color"] = self.accent_color
1840
+ payload["accent_color"] = self.accent_color.value
1726
1841
  if self.spoiler:
1727
1842
  payload["spoiler"] = self.spoiler
1728
1843
  if self.id is not None:
disagreement/py.typed ADDED
File without changes
disagreement/ui/modal.py CHANGED
@@ -74,7 +74,7 @@ def text_input(
74
74
 
75
75
  item = TextInput(
76
76
  label=label,
77
- custom_id=custom_id,
77
+ custom_id=custom_id or func.__name__,
78
78
  style=style,
79
79
  placeholder=placeholder,
80
80
  required=required,
disagreement/utils.py CHANGED
@@ -3,8 +3,71 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime, timezone
6
+ from typing import Any, AsyncIterator, Dict, Optional, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING: # pragma: no cover - for type hinting only
9
+ from .models import Message, TextChannel
6
10
 
7
11
 
8
12
  def utcnow() -> datetime:
9
13
  """Return the current timezone-aware UTC time."""
10
14
  return datetime.now(timezone.utc)
15
+
16
+
17
+ async def message_pager(
18
+ channel: "TextChannel",
19
+ *,
20
+ limit: Optional[int] = None,
21
+ before: Optional[str] = None,
22
+ after: Optional[str] = None,
23
+ ) -> AsyncIterator["Message"]:
24
+ """Asynchronously paginate a channel's messages.
25
+
26
+ Parameters
27
+ ----------
28
+ channel:
29
+ The :class:`TextChannel` to fetch messages from.
30
+ limit:
31
+ The maximum number of messages to yield. ``None`` fetches until no
32
+ more messages are returned.
33
+ before:
34
+ Fetch messages with IDs less than this snowflake.
35
+ after:
36
+ Fetch messages with IDs greater than this snowflake.
37
+
38
+ Yields
39
+ ------
40
+ Message
41
+ Messages in the channel, oldest first.
42
+ """
43
+
44
+ remaining = limit
45
+ last_id = before
46
+ while remaining is None or remaining > 0:
47
+ fetch_limit = 100
48
+ if remaining is not None:
49
+ fetch_limit = min(fetch_limit, remaining)
50
+
51
+ params: Dict[str, Any] = {"limit": fetch_limit}
52
+ if last_id is not None:
53
+ params["before"] = last_id
54
+ if after is not None:
55
+ params["after"] = after
56
+
57
+ data = await channel._client._http.request( # type: ignore[attr-defined]
58
+ "GET",
59
+ f"/channels/{channel.id}/messages",
60
+ params=params,
61
+ )
62
+
63
+ if not data:
64
+ break
65
+
66
+ for raw in data:
67
+ msg = channel._client.parse_message(raw) # type: ignore[attr-defined]
68
+ yield msg
69
+ last_id = msg.id
70
+ if remaining is not None:
71
+ remaining -= 1
72
+ if remaining == 0:
73
+ return
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: disagreement
3
- Version: 0.1.0rc1
3
+ Version: 0.1.0rc3
4
4
  Summary: A Python library for the Discord API.
5
5
  Author-email: Slipstream <me@slipstreamm.dev>
6
6
  License: BSD 3-Clause
@@ -12,22 +12,23 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: BSD License
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
15
16
  Classifier: Programming Language :: Python :: 3.11
16
17
  Classifier: Programming Language :: Python :: 3.12
17
18
  Classifier: Programming Language :: Python :: 3.13
18
19
  Classifier: Topic :: Software Development :: Libraries
19
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
21
  Classifier: Topic :: Internet
21
- Requires-Python: >=3.11
22
+ Requires-Python: >=3.10
22
23
  Description-Content-Type: text/markdown
23
24
  License-File: LICENSE
24
25
  Requires-Dist: aiohttp<4.0.0,>=3.9.0
25
26
  Provides-Extra: test
26
27
  Requires-Dist: pytest>=8.0.0; extra == "test"
27
28
  Requires-Dist: pytest-asyncio>=1.0.0; extra == "test"
28
- Requires-Dist: hypothesis>=6.89.0; extra == "test"
29
+ Requires-Dist: hypothesis>=6.132.0; extra == "test"
29
30
  Provides-Extra: dev
30
- Requires-Dist: dotenv>=0.0.5; extra == "dev"
31
+ Requires-Dist: python-dotenv>=1.0.0; extra == "dev"
31
32
  Dynamic: license-file
32
33
 
33
34
  # Disagreement
@@ -53,7 +54,7 @@ pip install disagreement
53
54
  pip install -e .
54
55
  ```
55
56
 
56
- Requires Python 3.11 or newer.
57
+ Requires Python 3.10 or newer.
57
58
 
58
59
  ## Basic Usage
59
60