disagreement 0.2.0rc1__py3-none-any.whl → 0.3.0b1__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.
@@ -1,162 +1,244 @@
1
- # disagreement/voice_client.py
2
- """Voice gateway and UDP audio client."""
3
-
4
- from __future__ import annotations
5
-
6
- import asyncio
7
- import contextlib
8
- import socket
9
- from typing import Optional, Sequence
10
-
11
- import aiohttp
12
-
13
- from .audio import AudioSource, FFmpegAudioSource
14
-
15
-
16
- class VoiceClient:
17
- """Handles the Discord voice WebSocket connection and UDP streaming."""
18
-
19
- def __init__(
20
- self,
21
- endpoint: str,
22
- session_id: str,
23
- token: str,
24
- guild_id: int,
25
- user_id: int,
26
- *,
27
- ws=None,
28
- udp: Optional[socket.socket] = None,
29
- loop: Optional[asyncio.AbstractEventLoop] = None,
30
- verbose: bool = False,
31
- ) -> None:
32
- self.endpoint = endpoint
33
- self.session_id = session_id
34
- self.token = token
35
- self.guild_id = str(guild_id)
36
- self.user_id = str(user_id)
37
- self._ws: Optional[aiohttp.ClientWebSocketResponse] = ws
38
- self._udp = udp
39
- self._session: Optional[aiohttp.ClientSession] = None
40
- self._heartbeat_task: Optional[asyncio.Task] = None
41
- self._heartbeat_interval: Optional[float] = None
42
- self._loop = loop or asyncio.get_event_loop()
43
- self.verbose = verbose
44
- self.ssrc: Optional[int] = None
45
- self.secret_key: Optional[Sequence[int]] = None
46
- self._server_ip: Optional[str] = None
47
- self._server_port: Optional[int] = None
48
- self._current_source: Optional[AudioSource] = None
49
- self._play_task: Optional[asyncio.Task] = None
50
-
51
- async def connect(self) -> None:
52
- if self._ws is None:
53
- self._session = aiohttp.ClientSession()
54
- self._ws = await self._session.ws_connect(self.endpoint)
55
-
56
- hello = await self._ws.receive_json()
57
- self._heartbeat_interval = hello["d"]["heartbeat_interval"] / 1000
58
- self._heartbeat_task = self._loop.create_task(self._heartbeat())
59
-
60
- await self._ws.send_json(
61
- {
62
- "op": 0,
63
- "d": {
64
- "server_id": self.guild_id,
65
- "user_id": self.user_id,
66
- "session_id": self.session_id,
67
- "token": self.token,
68
- },
69
- }
70
- )
71
-
72
- ready = await self._ws.receive_json()
73
- data = ready["d"]
74
- self.ssrc = data["ssrc"]
75
- self._server_ip = data["ip"]
76
- self._server_port = data["port"]
77
-
78
- if self._udp is None:
79
- self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
80
- self._udp.connect((self._server_ip, self._server_port))
81
-
82
- await self._ws.send_json(
83
- {
84
- "op": 1,
85
- "d": {
86
- "protocol": "udp",
87
- "data": {
88
- "address": self._udp.getsockname()[0],
89
- "port": self._udp.getsockname()[1],
90
- "mode": "xsalsa20_poly1305",
91
- },
92
- },
93
- }
94
- )
95
-
96
- session_desc = await self._ws.receive_json()
97
- self.secret_key = session_desc["d"].get("secret_key")
98
-
99
- async def _heartbeat(self) -> None:
100
- assert self._ws is not None
101
- assert self._heartbeat_interval is not None
102
- try:
103
- while True:
104
- await self._ws.send_json({"op": 3, "d": int(self._loop.time() * 1000)})
105
- await asyncio.sleep(self._heartbeat_interval)
106
- except asyncio.CancelledError:
107
- pass
108
-
109
- async def send_audio_frame(self, frame: bytes) -> None:
110
- if not self._udp:
111
- raise RuntimeError("UDP socket not initialised")
112
- self._udp.send(frame)
113
-
114
- async def _play_loop(self) -> None:
115
- assert self._current_source is not None
116
- try:
117
- while True:
118
- data = await self._current_source.read()
119
- if not data:
120
- break
121
- await self.send_audio_frame(data)
122
- finally:
123
- await self._current_source.close()
124
- self._current_source = None
125
- self._play_task = None
126
-
127
- async def stop(self) -> None:
128
- if self._play_task:
129
- self._play_task.cancel()
130
- with contextlib.suppress(asyncio.CancelledError):
131
- await self._play_task
132
- self._play_task = None
133
- if self._current_source:
134
- await self._current_source.close()
135
- self._current_source = None
136
-
137
- async def play(self, source: AudioSource, *, wait: bool = True) -> None:
138
- """|coro| Play an :class:`AudioSource` on the voice connection."""
139
-
140
- await self.stop()
141
- self._current_source = source
142
- self._play_task = self._loop.create_task(self._play_loop())
143
- if wait:
144
- await self._play_task
145
-
146
- async def play_file(self, filename: str, *, wait: bool = True) -> None:
147
- """|coro| Stream an audio file or URL using FFmpeg."""
148
-
149
- await self.play(FFmpegAudioSource(filename), wait=wait)
150
-
151
- async def close(self) -> None:
152
- await self.stop()
153
- if self._heartbeat_task:
154
- self._heartbeat_task.cancel()
155
- with contextlib.suppress(asyncio.CancelledError):
156
- await self._heartbeat_task
157
- if self._ws:
158
- await self._ws.close()
159
- if self._session:
160
- await self._session.close()
161
- if self._udp:
162
- self._udp.close()
1
+ # disagreement/voice_client.py
2
+ """Voice gateway and UDP audio client."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import contextlib
8
+ import socket
9
+ import threading
10
+ from typing import TYPE_CHECKING, Optional, Sequence
11
+
12
+ import aiohttp
13
+ # The following import is correct, but may be flagged by Pylance if the virtual
14
+ # environment is not configured correctly.
15
+ from nacl.secret import SecretBox
16
+
17
+ from .audio import AudioSink, AudioSource, FFmpegAudioSource
18
+ from .models import User
19
+
20
+ if TYPE_CHECKING:
21
+ from .client import Client
22
+
23
+
24
+ class VoiceClient:
25
+ """Handles the Discord voice WebSocket connection and UDP streaming."""
26
+
27
+ def __init__(
28
+ self,
29
+ client: Client,
30
+ endpoint: str,
31
+ session_id: str,
32
+ token: str,
33
+ guild_id: int,
34
+ user_id: int,
35
+ *,
36
+ ws=None,
37
+ udp: Optional[socket.socket] = None,
38
+ loop: Optional[asyncio.AbstractEventLoop] = None,
39
+ verbose: bool = False,
40
+ ) -> None:
41
+ self.client = client
42
+ self.endpoint = endpoint
43
+ self.session_id = session_id
44
+ self.token = token
45
+ self.guild_id = str(guild_id)
46
+ self.user_id = str(user_id)
47
+ self._ws: Optional[aiohttp.ClientWebSocketResponse] = ws
48
+ self._udp = udp
49
+ self._session: Optional[aiohttp.ClientSession] = None
50
+ self._heartbeat_task: Optional[asyncio.Task] = None
51
+ self._receive_task: Optional[asyncio.Task] = None
52
+ self._udp_receive_thread: Optional[threading.Thread] = None
53
+ self._heartbeat_interval: Optional[float] = None
54
+ try:
55
+ self._loop = loop or asyncio.get_running_loop()
56
+ except RuntimeError:
57
+ self._loop = asyncio.new_event_loop()
58
+ asyncio.set_event_loop(self._loop)
59
+ self.verbose = verbose
60
+ self.ssrc: Optional[int] = None
61
+ self.secret_key: Optional[Sequence[int]] = None
62
+ self._server_ip: Optional[str] = None
63
+ self._server_port: Optional[int] = None
64
+ self._current_source: Optional[AudioSource] = None
65
+ self._play_task: Optional[asyncio.Task] = None
66
+ self._sink: Optional[AudioSink] = None
67
+ self._ssrc_map: dict[int, int] = {}
68
+ self._ssrc_lock = threading.Lock()
69
+
70
+ async def connect(self) -> None:
71
+ if self._ws is None:
72
+ self._session = aiohttp.ClientSession()
73
+ self._ws = await self._session.ws_connect(self.endpoint)
74
+
75
+ hello = await self._ws.receive_json()
76
+ self._heartbeat_interval = hello["d"]["heartbeat_interval"] / 1000
77
+ self._heartbeat_task = self._loop.create_task(self._heartbeat())
78
+
79
+ await self._ws.send_json(
80
+ {
81
+ "op": 0,
82
+ "d": {
83
+ "server_id": self.guild_id,
84
+ "user_id": self.user_id,
85
+ "session_id": self.session_id,
86
+ "token": self.token,
87
+ },
88
+ }
89
+ )
90
+
91
+ ready = await self._ws.receive_json()
92
+ data = ready["d"]
93
+ self.ssrc = data["ssrc"]
94
+ self._server_ip = data["ip"]
95
+ self._server_port = data["port"]
96
+
97
+ if self._udp is None:
98
+ self._udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
99
+ self._udp.connect((self._server_ip, self._server_port))
100
+
101
+ await self._ws.send_json(
102
+ {
103
+ "op": 1,
104
+ "d": {
105
+ "protocol": "udp",
106
+ "data": {
107
+ "address": self._udp.getsockname()[0],
108
+ "port": self._udp.getsockname()[1],
109
+ "mode": "xsalsa20_poly1305",
110
+ },
111
+ },
112
+ }
113
+ )
114
+
115
+ session_desc = await self._ws.receive_json()
116
+ self.secret_key = session_desc["d"].get("secret_key")
117
+
118
+ async def _heartbeat(self) -> None:
119
+ assert self._ws is not None
120
+ assert self._heartbeat_interval is not None
121
+ try:
122
+ while True:
123
+ await self._ws.send_json({"op": 3, "d": int(self._loop.time() * 1000)})
124
+ await asyncio.sleep(self._heartbeat_interval)
125
+ except asyncio.CancelledError:
126
+ pass
127
+
128
+ async def _receive_loop(self) -> None:
129
+ assert self._ws is not None
130
+ while True:
131
+ try:
132
+ msg = await self._ws.receive_json()
133
+ op = msg.get("op")
134
+ data = msg.get("d")
135
+ if op == 5: # Speaking
136
+ user_id = int(data["user_id"])
137
+ ssrc = data["ssrc"]
138
+ with self._ssrc_lock:
139
+ self._ssrc_map[ssrc] = user_id
140
+ except (asyncio.CancelledError, aiohttp.ClientError):
141
+ break
142
+
143
+ def _udp_receive_loop(self) -> None:
144
+ assert self._udp is not None
145
+ assert self.secret_key is not None
146
+ box = SecretBox(bytes(self.secret_key))
147
+ while True:
148
+ try:
149
+ packet = self._udp.recv(4096)
150
+ if len(packet) < 12:
151
+ continue
152
+
153
+ ssrc = int.from_bytes(packet[8:12], "big")
154
+ with self._ssrc_lock:
155
+ if ssrc not in self._ssrc_map:
156
+ continue
157
+ user_id = self._ssrc_map[ssrc]
158
+ user = self.client._users.get(str(user_id))
159
+ if not user:
160
+ continue
161
+
162
+ decrypted = box.decrypt(packet[12:])
163
+ if self._sink:
164
+ self._sink.write(user, decrypted)
165
+ except (socket.error, asyncio.CancelledError):
166
+ break
167
+ except Exception as e:
168
+ if self.verbose:
169
+ print(f"Error in UDP receive loop: {e}")
170
+
171
+ async def send_audio_frame(self, frame: bytes) -> None:
172
+ if not self._udp:
173
+ raise RuntimeError("UDP socket not initialised")
174
+ self._udp.send(frame)
175
+
176
+ async def _play_loop(self) -> None:
177
+ assert self._current_source is not None
178
+ try:
179
+ while True:
180
+ data = await self._current_source.read()
181
+ if not data:
182
+ break
183
+ await self.send_audio_frame(data)
184
+ finally:
185
+ await self._current_source.close()
186
+ self._current_source = None
187
+ self._play_task = None
188
+
189
+ async def stop(self) -> None:
190
+ if self._play_task:
191
+ self._play_task.cancel()
192
+ with contextlib.suppress(asyncio.CancelledError):
193
+ await self._play_task
194
+ self._play_task = None
195
+ if self._current_source:
196
+ await self._current_source.close()
197
+ self._current_source = None
198
+
199
+ async def play(self, source: AudioSource, *, wait: bool = True) -> None:
200
+ """|coro| Play an :class:`AudioSource` on the voice connection."""
201
+
202
+ await self.stop()
203
+ self._current_source = source
204
+ self._play_task = self._loop.create_task(self._play_loop())
205
+ if wait:
206
+ await self._play_task
207
+
208
+ async def play_file(self, filename: str, *, wait: bool = True) -> None:
209
+ """|coro| Stream an audio file or URL using FFmpeg."""
210
+
211
+ await self.play(FFmpegAudioSource(filename), wait=wait)
212
+
213
+ def listen(self, sink: AudioSink) -> None:
214
+ """Start listening to voice and routing to a sink."""
215
+ if not isinstance(sink, AudioSink):
216
+ raise TypeError("sink must be an AudioSink instance")
217
+
218
+ self._sink = sink
219
+ if not self._udp_receive_thread:
220
+ self._udp_receive_thread = threading.Thread(
221
+ target=self._udp_receive_loop, daemon=True
222
+ )
223
+ self._udp_receive_thread.start()
224
+
225
+ async def close(self) -> None:
226
+ await self.stop()
227
+ if self._heartbeat_task:
228
+ self._heartbeat_task.cancel()
229
+ with contextlib.suppress(asyncio.CancelledError):
230
+ await self._heartbeat_task
231
+ if self._receive_task:
232
+ self._receive_task.cancel()
233
+ with contextlib.suppress(asyncio.CancelledError):
234
+ await self._receive_task
235
+ if self._ws:
236
+ await self._ws.close()
237
+ if self._session:
238
+ await self._session.close()
239
+ if self._udp:
240
+ self._udp.close()
241
+ if self._udp_receive_thread:
242
+ self._udp_receive_thread.join(timeout=1)
243
+ if self._sink:
244
+ self._sink.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: disagreement
3
- Version: 0.2.0rc1
3
+ Version: 0.3.0b1
4
4
  Summary: A Python library for the Discord API.
5
5
  Author-email: Slipstream <me@slipstreamm.dev>
6
6
  License: BSD 3-Clause
@@ -23,6 +23,7 @@ Requires-Python: >=3.10
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
25
  Requires-Dist: aiohttp<4.0.0,>=3.9.0
26
+ Requires-Dist: PyNaCl<2.0.0,>=1.5.0
26
27
  Provides-Extra: test
27
28
  Requires-Dist: pytest>=8.0.0; extra == "test"
28
29
  Requires-Dist: pytest-asyncio>=1.0.0; extra == "test"
@@ -1,20 +1,21 @@
1
- disagreement/__init__.py,sha256=tZlZwEhqnBj3CEfPX2BWXXvGIc5OIiLtzuybRG7w1wA,1184
2
- disagreement/audio.py,sha256=P6inobI8CNhNVkaRKU58RMYtLq1RrSREioF0Mui5VlA,3351
3
- disagreement/cache.py,sha256=juabGFl4naQih5OUIVN2aN-vAfw2ZC2cI38s4nGEn8U,1525
4
- disagreement/client.py,sha256=WJ1xLiXMha0_9i0rUDxXiMWXSG80_fXylq3Qmf9rr7k,59855
1
+ disagreement/__init__.py,sha256=byWcO3yzDBdtlF2I-7f72JVlNnwQnbu88kr2PUeKIdY,1183
2
+ disagreement/audio.py,sha256=nJ2A8glYVZwH5HgsqxOwqCgkOuWi0-bmgccPaV5K8-Y,3739
3
+ disagreement/cache.py,sha256=2BACo4s2n4TyJejgNlheWjFOT6AWJHweHhO_LPhwkdI,2505
4
+ disagreement/caching.py,sha256=_AKdhdWYOhMw2r6u0rVpdoU9_fwjzzDlxhTs73tgUXg,3839
5
+ disagreement/client.py,sha256=e72n9zgOUWGCjfSB0zIhFQ2WvOJijWzKrxrPRtpL7Gc,65682
5
6
  disagreement/color.py,sha256=0RumZU9geS51rmmywwotmkXFc8RyQghOviRGGrHmvW4,4495
6
7
  disagreement/components.py,sha256=tEYJ2RHVpIFtZuPPxZ0v8ssUw_x7ybhYBzHNsRiXXvU,5250
7
8
  disagreement/enums.py,sha256=Km9rzmbkYdBpba3fDAv9YYtXDROoRpKuQfkMavsiY0s,11069
8
9
  disagreement/error_handler.py,sha256=c2lb6aTMnhTtITQuR6axZUtEaasYKUgmdSxAHEkeq50,1028
9
10
  disagreement/errors.py,sha256=XiYVPy8uFUGVi_EIf81yK7QbC7KyN4JHplSJSWw2RRk,3185
10
- disagreement/event_dispatcher.py,sha256=mp4LVhIj0SW1P2NruqbYpZoYH33X5rXvkAl3-RK40kE,11460
11
- disagreement/gateway.py,sha256=AxfGsSxu4eOWwpL3LQiNfcQVR3hyj33N9KfaPy0h8OU,24487
12
- disagreement/http.py,sha256=TOGF2LBnsg4hTrP0sFBscKz1VVM_qZ8eoPZfBoQQPQw,37063
11
+ disagreement/event_dispatcher.py,sha256=gdT5n9JzuWH6MXMvc4ZcoeyoSMJ9v973xC5rpufaTeY,11479
12
+ disagreement/gateway.py,sha256=Orub9imXCvq7zKqBZqYxozMaI45B23SjCDaeYzB2SHI,26920
13
+ disagreement/http.py,sha256=zA3I9QUMHNjvIxDUiAeCVFaMUyn1QdERfJ6dsxQr2A0,40864
13
14
  disagreement/hybrid_context.py,sha256=VYCmcreTZdPBU9v-Cy48W38vgWO2t8nM2ulC6_z4HjU,1095
14
15
  disagreement/i18n.py,sha256=1L4rcFuKP0XjHk9dVwbNh4BkLk2ZlxxZ_-tecGWa9S0,718
15
16
  disagreement/interactions.py,sha256=aUZwwEOLsEds15i6G-rxmSSDCDmaxz_cfoTYS4tv6Ao,21735
16
17
  disagreement/logging_config.py,sha256=4q6baQPE6X_0lfaBTFMU1uqc03x5SbJqo2hsApdDFac,686
17
- disagreement/models.py,sha256=Km75XDUiRV3gzhSPYDm2AByQEw2koZ-gyY1urvYffTE,82512
18
+ disagreement/models.py,sha256=m88dlN8FrCZclb8p_lW6okljAVAnFib7-pkh1yKl1fk,95201
18
19
  disagreement/oauth.py,sha256=TfDdCwg1J7osM9wDi61dtNBA5BrQk5DeQrrHsYycH34,2810
19
20
  disagreement/permissions.py,sha256=7g5cIlg-evHXOL0-pmtT5EwqcB-stXot1HZSLz724sE,3008
20
21
  disagreement/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -22,7 +23,7 @@ disagreement/rate_limiter.py,sha256=ubwR_UTPs2MotipBdtqpgwQKx0IHt2I3cdfFcXTFv7g,
22
23
  disagreement/shard_manager.py,sha256=e9F8tx_4IEOlTX3-S3t51lfJToc6Ue3RVBzoNAiVKxs,2161
23
24
  disagreement/typing.py,sha256=_1oFWfZ4HyH5Q3bnF7CO24s79z-3_B5Qb69kWiwLhhU,1242
24
25
  disagreement/utils.py,sha256=mz7foTCOAmUv9n8EcdZeiFarwqB14xHOG8o0p8tFuKA,2014
25
- disagreement/voice_client.py,sha256=i_67gJ-SQWi9YH-pgtFM8N0lCYznyuQImyL-mf2O7KQ,5384
26
+ disagreement/voice_client.py,sha256=ygle5grurpSKF-0y5YG6vwF7Ise5Pf1uNjz55mr6Y_4,8717
26
27
  disagreement/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
28
  disagreement/ext/loader.py,sha256=9_uULvNAa-a6UiaeQhWglwgIrHEPKbf9bnWtSL1KV5Q,1408
28
29
  disagreement/ext/tasks.py,sha256=b14KI-btikbrjPlD76md3Ggt6znrxPqr7TDarU4PYBg,7269
@@ -33,11 +34,11 @@ disagreement/ext/app_commands/converters.py,sha256=J1VEmo-7H9K7kGPJodu5FX4RmFFI1
33
34
  disagreement/ext/app_commands/decorators.py,sha256=dKiD4ZEsafRoPvfgn9zuQ9vvXXo2qYTMquHvyUM1604,23251
34
35
  disagreement/ext/app_commands/handler.py,sha256=KCMi5NEusuyLYo7Vk4sqLqXAJI5r3ppI0MNLUh0kU2c,28781
35
36
  disagreement/ext/app_commands/hybrid.py,sha256=u3kHNbVfY3-liymgzEIkFO5YV3WM_DqwytzdN9EXWMY,3330
36
- disagreement/ext/commands/__init__.py,sha256=miejXIfft2kq2Q4Lej28awSgQXIEEeEuaBhR3M7f9tk,1230
37
+ disagreement/ext/commands/__init__.py,sha256=Zko_qVPz1WUjBLcR2bnS3mKMX_YmU4AZ8kuFw9bvRRg,1363
37
38
  disagreement/ext/commands/cog.py,sha256=-F2ZOXXC07r96xlt9NomRgqlIqlcxzBiha2Zhg1DVp4,6845
38
39
  disagreement/ext/commands/converters.py,sha256=mh8xJr1FIiah6bdYy0KsdccfYcPii2Yc_IdhzCTw5uE,5864
39
- disagreement/ext/commands/core.py,sha256=4AO-U9xFyDetWeQUZiqX_g4zZfue0-9s8QBnHIb2BTc,21265
40
- disagreement/ext/commands/decorators.py,sha256=fOhppBae8gt-9QI1YqUzDctwOXmMBdAK_JaUJLNWHww,7427
40
+ disagreement/ext/commands/core.py,sha256=l6-rAAzeVERASyKd-qp2ttHlt5YgzuFwHTR4_JtXZnw,27053
41
+ disagreement/ext/commands/decorators.py,sha256=BdhfiTsetu-oOIZn7gwUu2YDAmlzio7ijYFvov7m9pA,10469
41
42
  disagreement/ext/commands/errors.py,sha256=L6losXKye62BqDl42fXxgkuAkG92W-OcqH9uwEgabb8,2301
42
43
  disagreement/ext/commands/help.py,sha256=yw0ydupOsPwmnhsIIoxa93xjj9MAcBcGfD8BXa7V8G8,1456
43
44
  disagreement/ext/commands/view.py,sha256=3Wo4gGJX9fb65qw8yHFwMjnAeJvMJAx19rZNHz-ZDUs,3315
@@ -46,9 +47,9 @@ disagreement/ui/button.py,sha256=GHbY3-yMrvv6u674-qYONocuC1e2a0flEWjPKwJXKDo,316
46
47
  disagreement/ui/item.py,sha256=bm-EmQEZpe8Kt8JrRw-o0uQdccgjErORcFsBqaXOcV8,1112
47
48
  disagreement/ui/modal.py,sha256=w0ZEVslXzx2-RWUP4jdVB54zDuT81jpueVWZ70byFnI,4137
48
49
  disagreement/ui/select.py,sha256=XjWRlWkA09QZaDDLn-wDDOWIuj0Mb4VCWJEOAaExZXw,3018
49
- disagreement/ui/view.py,sha256=QhWoYt39QKXwl1X6Mkm5gNNEqd8bt7O505lSpiG0L04,5567
50
- disagreement-0.2.0rc1.dist-info/licenses/LICENSE,sha256=zfmtgJhVFHnqT7a8LAQFthXu5bP7EEHmEL99trV66Ew,1474
51
- disagreement-0.2.0rc1.dist-info/METADATA,sha256=ZEw0xKaAbZyXurATHmXCYwODfC6pr98jo27SlubhC_Q,5382
52
- disagreement-0.2.0rc1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
53
- disagreement-0.2.0rc1.dist-info/top_level.txt,sha256=t7FY_3iaYhdB6X6y9VybJ2j7UZbVeRUe9wRgH8d5Gtk,13
54
- disagreement-0.2.0rc1.dist-info/RECORD,,
50
+ disagreement/ui/view.py,sha256=bmDcX-YG-_exTVTfvMCDozo4D1vRQNFlXc8Ap21DVKE,5859
51
+ disagreement-0.3.0b1.dist-info/licenses/LICENSE,sha256=zfmtgJhVFHnqT7a8LAQFthXu5bP7EEHmEL99trV66Ew,1474
52
+ disagreement-0.3.0b1.dist-info/METADATA,sha256=NRhQzjjYG7vcD7X9RQYr_alVA4SnCQJvJbf8Sz1J0Tc,5417
53
+ disagreement-0.3.0b1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
54
+ disagreement-0.3.0b1.dist-info/top_level.txt,sha256=t7FY_3iaYhdB6X6y9VybJ2j7UZbVeRUe9wRgH8d5Gtk,13
55
+ disagreement-0.3.0b1.dist-info/RECORD,,