plexo-cli 0.1.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.
plexo/core/engine.py ADDED
@@ -0,0 +1,266 @@
1
+ """Parallel multi-adapter bonding download engine.
2
+
3
+ Distributes file chunks across verified network adapters concurrently,
4
+ tracks live per-adapter throughput, and writes to disk via sparse file mapping.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import concurrent.futures
10
+ import queue
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Callable, List, Optional
16
+ from .adapter import NetworkAdapter, get_bonded_pool
17
+ from .client import BoundRangeClient, ProbeResult, probe_url
18
+ from .qos import QoSWatchdog
19
+ from .sparse import SparseFileWriter
20
+
21
+
22
+ @dataclass
23
+ class DownloadChunk:
24
+ chunk_id: int
25
+ start_byte: int
26
+ end_byte: int
27
+ retries: int = 0
28
+
29
+
30
+ @dataclass
31
+ class ProgressUpdate:
32
+ total_bytes: int
33
+ downloaded_bytes: int
34
+ speed_bytes_sec: float
35
+ adapter_speeds: dict[str, float] # ip -> bytes/sec
36
+ is_completed: bool = False
37
+ filename: str = ""
38
+
39
+
40
+ class BondingDownloadEngine:
41
+ """Orchestrates multi-adapter parallel downloads."""
42
+
43
+ def __init__(
44
+ self,
45
+ url: str,
46
+ output_path: Optional[str | Path] = None,
47
+ adapters: Optional[List[NetworkAdapter]] = None,
48
+ chunk_size: int = 8 * 1024 * 1024, # 8 MB default chunk
49
+ workers_per_adapter: int = 2,
50
+ ):
51
+ self.url = url
52
+ self.chunk_size = chunk_size
53
+ self.workers_per_adapter = workers_per_adapter
54
+ self.adapters = adapters or get_bonded_pool()
55
+ self.output_path = output_path
56
+
57
+ self.probe: Optional[ProbeResult] = None
58
+ self.watchdog = QoSWatchdog()
59
+ self._cancel_event = threading.Event()
60
+ self._completed_bytes = 0
61
+ self._bytes_lock = threading.Lock()
62
+ self._start_time = 0.0
63
+
64
+ def _prepare(self) -> ProbeResult:
65
+ """Probes the remote server and sets output path."""
66
+ self.probe = probe_url(self.url)
67
+
68
+ if not self.output_path:
69
+ self.output_path = Path.cwd() / self.probe.filename
70
+ else:
71
+ self.output_path = Path(self.output_path)
72
+ if self.output_path.is_dir():
73
+ self.output_path = self.output_path / self.probe.filename
74
+
75
+ return self.probe
76
+
77
+ def execute(
78
+ self,
79
+ progress_callback: Optional[Callable[[ProgressUpdate], None]] = None,
80
+ ) -> Path:
81
+ """Executes the bonded download and returns final output file path."""
82
+ probe = self._prepare()
83
+ target_path = Path(self.output_path)
84
+ self._start_time = time.time()
85
+
86
+ # If file doesn't support ranges or file size unknown, fall back to single-stream
87
+ if not probe.accepts_ranges or probe.total_bytes <= 0:
88
+ return self._execute_single_stream(probe, target_path, progress_callback)
89
+
90
+ return self._execute_bonded_parallel(probe, target_path, progress_callback)
91
+
92
+ def _execute_single_stream(
93
+ self,
94
+ probe: ProbeResult,
95
+ target_path: Path,
96
+ progress_callback: Optional[Callable[[ProgressUpdate], None]],
97
+ ) -> Path:
98
+ """Fallback for non-range servers."""
99
+ adapter = self.adapters[0] if self.adapters else None
100
+ local_ip = adapter.ip_address if adapter else "0.0.0.0"
101
+ client = BoundRangeClient(local_ip=local_ip)
102
+
103
+ target_path.parent.mkdir(parents=True, exist_ok=True)
104
+ downloaded = 0
105
+
106
+ with open(target_path, "wb") as f:
107
+ for chunk in client.fetch_range(probe.url, 0, probe.total_bytes - 1 if probe.total_bytes > 0 else 0):
108
+ f.write(chunk)
109
+ downloaded += len(chunk)
110
+ if progress_callback:
111
+ elapsed = max(0.001, time.time() - self._start_time)
112
+ progress_callback(
113
+ ProgressUpdate(
114
+ total_bytes=probe.total_bytes or downloaded,
115
+ downloaded_bytes=downloaded,
116
+ speed_bytes_sec=downloaded / elapsed,
117
+ adapter_speeds={local_ip: downloaded / elapsed},
118
+ is_completed=False,
119
+ filename=target_path.name,
120
+ )
121
+ )
122
+
123
+ if progress_callback:
124
+ elapsed = max(0.001, time.time() - self._start_time)
125
+ progress_callback(
126
+ ProgressUpdate(
127
+ total_bytes=downloaded,
128
+ downloaded_bytes=downloaded,
129
+ speed_bytes_sec=downloaded / elapsed,
130
+ adapter_speeds={local_ip: downloaded / elapsed},
131
+ is_completed=True,
132
+ filename=target_path.name,
133
+ )
134
+ )
135
+
136
+ return target_path
137
+
138
+ def _execute_bonded_parallel(
139
+ self,
140
+ probe: ProbeResult,
141
+ target_path: Path,
142
+ progress_callback: Optional[Callable[[ProgressUpdate], None]],
143
+ ) -> Path:
144
+ """Executes multi-socket bonded chunk downloading."""
145
+ total_bytes = probe.total_bytes
146
+ chunk_queue: queue.Queue[DownloadChunk] = queue.Queue()
147
+
148
+ # Split into chunks
149
+ offset = 0
150
+ chunk_id = 0
151
+ while offset < total_bytes:
152
+ end = min(offset + self.chunk_size - 1, total_bytes - 1)
153
+ chunk_queue.put(DownloadChunk(chunk_id=chunk_id, start_byte=offset, end_byte=end))
154
+ offset = end + 1
155
+ chunk_id += 1
156
+
157
+ total_chunks = chunk_id
158
+
159
+ # Register adapters in QoS watchdog
160
+ for a in self.adapters:
161
+ self.watchdog.register(a.name, a.ip_address, a.kind)
162
+
163
+ # Pre-allocate sparse file
164
+ with SparseFileWriter(target_path, total_bytes) as writer:
165
+ workers = []
166
+ with concurrent.futures.ThreadPoolExecutor(
167
+ max_workers=len(self.adapters) * self.workers_per_adapter
168
+ ) as executor:
169
+ for adapter in self.adapters:
170
+ for _ in range(self.workers_per_adapter):
171
+ future = executor.submit(
172
+ self._worker_loop,
173
+ adapter,
174
+ chunk_queue,
175
+ writer,
176
+ probe.url,
177
+ progress_callback,
178
+ )
179
+ workers.append(future)
180
+
181
+ # Wait for all workers or cancellation
182
+ for future in concurrent.futures.as_completed(workers):
183
+ # Raise any unexpected exceptions
184
+ future.result()
185
+
186
+ if progress_callback:
187
+ elapsed = max(0.001, time.time() - self._start_time)
188
+ progress_callback(
189
+ ProgressUpdate(
190
+ total_bytes=total_bytes,
191
+ downloaded_bytes=total_bytes,
192
+ speed_bytes_sec=total_bytes / elapsed,
193
+ adapter_speeds={a.ip_address: self.watchdog.get(a.ip_address).speed_bytes_per_sec for a in self.adapters},
194
+ is_completed=True,
195
+ filename=target_path.name,
196
+ )
197
+ )
198
+
199
+ return target_path
200
+
201
+ def _worker_loop(
202
+ self,
203
+ adapter: NetworkAdapter,
204
+ chunk_queue: queue.Queue[DownloadChunk],
205
+ writer: SparseFileWriter,
206
+ url: str,
207
+ progress_callback: Optional[Callable[[ProgressUpdate], None]],
208
+ ) -> None:
209
+ """Worker thread dedicated to a specific network adapter."""
210
+ client = BoundRangeClient(local_ip=adapter.ip_address)
211
+ metric = self.watchdog.get(adapter.ip_address)
212
+
213
+ while not self._cancel_event.is_set():
214
+ try:
215
+ chunk = chunk_queue.get_nowait()
216
+ except queue.Empty:
217
+ break
218
+
219
+ metric.active_chunks += 1
220
+ chunk_data = bytearray()
221
+ success = False
222
+
223
+ try:
224
+ def on_bytes(count: int):
225
+ metric.record_bytes(count)
226
+ with self._bytes_lock:
227
+ self._completed_bytes += count
228
+ if progress_callback:
229
+ elapsed = max(0.001, time.time() - self._start_time)
230
+ speeds = {
231
+ a.ip_address: self.watchdog.get(a.ip_address).speed_bytes_per_sec
232
+ for a in self.adapters
233
+ }
234
+ progress_callback(
235
+ ProgressUpdate(
236
+ total_bytes=self.probe.total_bytes,
237
+ downloaded_bytes=self._completed_bytes,
238
+ speed_bytes_sec=self._completed_bytes / elapsed,
239
+ adapter_speeds=speeds,
240
+ is_completed=False,
241
+ filename=self.output_path.name if self.output_path else "",
242
+ )
243
+ )
244
+
245
+ for block in client.fetch_range(
246
+ url, chunk.start_byte, chunk.end_byte, on_bytes=on_bytes
247
+ ):
248
+ chunk_data.extend(block)
249
+
250
+ # Write directly into sparse file offset
251
+ writer.write_chunk(chunk.start_byte, bytes(chunk_data))
252
+ success = True
253
+
254
+ except Exception as err:
255
+ metric.record_error()
256
+ chunk.retries += 1
257
+ if chunk.retries < 5:
258
+ # Re-queue for another healthy adapter
259
+ chunk_queue.put(chunk)
260
+ else:
261
+ self._cancel_event.set()
262
+ raise RuntimeError(f"Chunk {chunk.chunk_id} failed permanently: {err}")
263
+
264
+ finally:
265
+ metric.active_chunks -= 1
266
+ chunk_queue.task_done()
plexo/core/qos.py ADDED
@@ -0,0 +1,76 @@
1
+ """QoS watchdog and adapter health monitoring.
2
+
3
+ Tracks real-time throughput, detects hotspot stalls, and triggers
4
+ failover to healthy connections.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict
12
+
13
+
14
+ @dataclass
15
+ class AdapterMetrics:
16
+ """Live performance metrics for a specific adapter."""
17
+ name: str
18
+ ip_address: str
19
+ kind: str
20
+ bytes_transferred: int = 0
21
+ active_chunks: int = 0
22
+ last_byte_time: float = field(default_factory=time.time)
23
+ start_time: float = field(default_factory=time.time)
24
+ errors: int = 0
25
+ is_healthy: bool = True
26
+
27
+ def record_bytes(self, count: int) -> None:
28
+ self.bytes_transferred += count
29
+ self.last_byte_time = time.time()
30
+
31
+ def record_error(self) -> None:
32
+ self.errors += 1
33
+ if self.errors > 3:
34
+ self.is_healthy = False
35
+
36
+ @property
37
+ def speed_bytes_per_sec(self) -> float:
38
+ elapsed = time.time() - self.start_time
39
+ if elapsed <= 0:
40
+ return 0.0
41
+ return self.bytes_transferred / elapsed
42
+
43
+ @property
44
+ def speed_mbps(self) -> float:
45
+ return (self.speed_bytes_per_sec * 8) / (1024 * 1024)
46
+
47
+ @property
48
+ def is_stalled(self) -> bool:
49
+ """Returns true if active chunks exist but no data received for > 8 seconds."""
50
+ if self.active_chunks > 0 and (time.time() - self.last_byte_time > 8.0):
51
+ return True
52
+ return False
53
+
54
+
55
+ class QoSWatchdog:
56
+ """Coordinates multi-adapter health and failure detection."""
57
+
58
+ def __init__(self):
59
+ self.adapters: Dict[str, AdapterMetrics] = {}
60
+
61
+ def register(self, name: str, ip_address: str, kind: str) -> AdapterMetrics:
62
+ metric = AdapterMetrics(name=name, ip_address=ip_address, kind=kind)
63
+ self.adapters[ip_address] = metric
64
+ return metric
65
+
66
+ def get(self, ip_address: str) -> AdapterMetrics:
67
+ return self.adapters[ip_address]
68
+
69
+ def check_health(self) -> Dict[str, bool]:
70
+ """Audits all adapters and returns active health status."""
71
+ status = {}
72
+ for ip, metric in self.adapters.items():
73
+ if metric.is_stalled:
74
+ metric.is_healthy = False
75
+ status[ip] = metric.is_healthy
76
+ return status
plexo/core/sparse.py ADDED
@@ -0,0 +1,92 @@
1
+ """Sparse file allocation and concurrent chunk writer.
2
+
3
+ Allows multiple worker threads/tasks to write disjoint byte ranges
4
+ directly into their target offsets concurrently.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import threading
12
+ from pathlib import Path
13
+ from typing import BinaryIO, Optional
14
+
15
+
16
+ class SparseFileWriter:
17
+ """Manages thread-safe random-access writes into a pre-allocated file."""
18
+
19
+ def __init__(self, target_path: str | Path, total_bytes: int):
20
+ self.target_path = Path(target_path).resolve()
21
+ self.total_bytes = total_bytes
22
+ self._lock = threading.Lock()
23
+ self._file: Optional[BinaryIO] = None
24
+ self._written_bytes = 0
25
+
26
+ # Ensure parent directory exists
27
+ self.target_path.parent.mkdir(parents=True, exist_ok=True)
28
+ self._allocate()
29
+
30
+ def _allocate(self) -> None:
31
+ """Pre-allocates the file size on disk."""
32
+ with open(self.target_path, "wb") as f:
33
+ if self.total_bytes > 0:
34
+ f.seek(self.total_bytes - 1)
35
+ f.write(b"\0")
36
+ f.flush()
37
+
38
+ # Open in r+b mode for concurrent random-access writes
39
+ self._file = open(self.target_path, "r+b")
40
+
41
+ def write_chunk(self, offset: int, data: bytes) -> int:
42
+ """Writes a byte block at the specified offset."""
43
+ if not self._file:
44
+ raise RuntimeError("File writer is closed.")
45
+
46
+ length = len(data)
47
+ if offset + length > self.total_bytes:
48
+ raise ValueError(
49
+ f"Write out of bounds: offset {offset} + len {length} > total {self.total_bytes}"
50
+ )
51
+
52
+ with self._lock:
53
+ self._file.seek(offset)
54
+ self._file.write(data)
55
+ self._written_bytes += length
56
+
57
+ return length
58
+
59
+ def flush(self) -> None:
60
+ """Flushes written buffers to disk."""
61
+ with self._lock:
62
+ if self._file:
63
+ self._file.flush()
64
+ try:
65
+ os.fsync(self._file.fileno())
66
+ except OSError:
67
+ pass
68
+
69
+ def close(self) -> None:
70
+ """Closes the underlying file handle."""
71
+ with self._lock:
72
+ if self._file:
73
+ try:
74
+ self._file.flush()
75
+ self._file.close()
76
+ finally:
77
+ self._file = None
78
+
79
+ def calculate_checksum(self, algorithm: str = "sha256") -> str:
80
+ """Calculates file checksum to verify integrity."""
81
+ self.flush()
82
+ hasher = hashlib.new(algorithm)
83
+ with open(self.target_path, "rb") as f:
84
+ while chunk := f.read(1024 * 1024): # 1 MB blocks
85
+ hasher.update(chunk)
86
+ return hasher.hexdigest()
87
+
88
+ def __enter__(self) -> "SparseFileWriter":
89
+ return self
90
+
91
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
92
+ self.close()
@@ -0,0 +1 @@
1
+ """Local forward proxy module for zero-touch bandwidth bonding."""
plexo/proxy/server.py ADDED
@@ -0,0 +1,170 @@
1
+ """Local HTTP/HTTPS forward proxy for accelerating terminal tools (pip, git, curl).
2
+
3
+ Routes requests through the multi-adapter bonding engine or tunnels HTTPS.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import socket
10
+ from typing import Optional
11
+ from rich.console import Console
12
+ from ..core.adapter import get_bonded_pool
13
+
14
+
15
+ class LocalBondedProxy:
16
+ """Asynchronous local HTTP/HTTPS accelerator proxy."""
17
+
18
+ def __init__(self, host: str = "127.0.0.1", port: int = 8899, console: Optional[Console] = None):
19
+ self.host = host
20
+ self.port = port
21
+ self.console = console or Console()
22
+ self.server: Optional[asyncio.Server] = None
23
+ self._adapters = []
24
+
25
+ async def start(self) -> None:
26
+ """Starts the local proxy server."""
27
+ self._adapters = get_bonded_pool()
28
+ self.server = await asyncio.start_server(
29
+ self._handle_client, self.host, self.port
30
+ )
31
+ addrs = ", ".join(str(sock.getsockname()) for sock in self.server.sockets)
32
+ self.console.print(
33
+ f"[bold green]Plexo Proxy running on {self.host}:{self.port}[/bold green]"
34
+ )
35
+ self.console.print(
36
+ f"[cyan]Bonding across {len(self._adapters)} network interfaces:[/cyan]"
37
+ )
38
+ for a in self._adapters:
39
+ self.console.print(f" * {a.name} ({a.ip_address})")
40
+
41
+ self.console.print(
42
+ "\n[dim]To use with pip:[/dim]\n"
43
+ f" pip install <package> --proxy http://{self.host}:{self.port}\n\n"
44
+ "[dim]To use globally in terminal:[/dim]\n"
45
+ f" $env:HTTPS_PROXY = \"http://{self.host}:{self.port}\"\n"
46
+ )
47
+
48
+ async with self.server:
49
+ await self.server.serve_forever()
50
+
51
+ async def _handle_client(
52
+ self, client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter
53
+ ) -> None:
54
+ """Handles client connection (HTTP GET or HTTPS CONNECT)."""
55
+ try:
56
+ request_line = await client_reader.readline()
57
+ if not request_line:
58
+ client_writer.close()
59
+ return
60
+
61
+ words = request_line.decode("latin1").split()
62
+ if len(words) < 2:
63
+ client_writer.close()
64
+ return
65
+
66
+ method, url = words[0], words[1]
67
+
68
+ if method.upper() == "CONNECT":
69
+ # HTTPS Tunneling
70
+ await self._handle_connect(url, client_reader, client_writer)
71
+ else:
72
+ # Plain HTTP proxy
73
+ await self._handle_http(method, url, request_line, client_reader, client_writer)
74
+
75
+ except Exception:
76
+ try:
77
+ client_writer.close()
78
+ except Exception:
79
+ pass
80
+
81
+ async def _handle_connect(
82
+ self, target: str, client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter
83
+ ) -> None:
84
+ """Tunnels HTTPS connections directly."""
85
+ host, port_str = target.split(":") if ":" in target else (target, "443")
86
+ port = int(port_str)
87
+
88
+ try:
89
+ # Round-robin or primary adapter source IP
90
+ adapter = self._adapters[0] if self._adapters else None
91
+ local_addr = (adapter.ip_address, 0) if adapter else None
92
+
93
+ remote_reader, remote_writer = await asyncio.open_connection(
94
+ host, port, local_addr=local_addr
95
+ )
96
+
97
+ client_writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
98
+ await client_writer.drain()
99
+
100
+ # Bidirectional pipe
101
+ await asyncio.gather(
102
+ self._pipe(client_reader, remote_writer),
103
+ self._pipe(remote_reader, client_writer),
104
+ return_exceptions=True,
105
+ )
106
+ except Exception:
107
+ client_writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
108
+ await client_writer.drain()
109
+ client_writer.close()
110
+
111
+ async def _handle_http(
112
+ self,
113
+ method: str,
114
+ url: str,
115
+ initial_line: bytes,
116
+ client_reader: asyncio.StreamReader,
117
+ client_writer: asyncio.StreamWriter,
118
+ ) -> None:
119
+ """Forwards standard HTTP requests."""
120
+ # Read remaining headers
121
+ headers = bytearray(initial_line)
122
+ while True:
123
+ line = await client_reader.readline()
124
+ headers.extend(line)
125
+ if line == b"\r\n" or not line:
126
+ break
127
+
128
+ # Parse host from URL
129
+ from urllib.parse import urlparse
130
+ parsed = urlparse(url)
131
+ host = parsed.hostname or "localhost"
132
+ port = parsed.port or 80
133
+
134
+ try:
135
+ remote_reader, remote_writer = await asyncio.open_connection(host, port)
136
+ remote_writer.write(headers)
137
+ await remote_writer.drain()
138
+
139
+ await asyncio.gather(
140
+ self._pipe(client_reader, remote_writer),
141
+ self._pipe(remote_reader, client_writer),
142
+ return_exceptions=True,
143
+ )
144
+ except Exception:
145
+ client_writer.close()
146
+
147
+ async def _pipe(
148
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
149
+ ) -> None:
150
+ """Pipes bytes between reader and writer until EOF."""
151
+ try:
152
+ while not reader.at_eof():
153
+ data = await reader.read(65536)
154
+ if not data:
155
+ break
156
+ writer.write(data)
157
+ await writer.drain()
158
+ except Exception:
159
+ pass
160
+ finally:
161
+ try:
162
+ writer.close()
163
+ except Exception:
164
+ pass
165
+
166
+
167
+ def run_proxy(host: str = "127.0.0.1", port: int = 8899) -> None:
168
+ """Synchronous launcher for the proxy server."""
169
+ proxy = LocalBondedProxy(host=host, port=port)
170
+ asyncio.run(proxy.start())
@@ -0,0 +1 @@
1
+ """Plexo BitTorrent multi-homing engine."""
@@ -0,0 +1,62 @@
1
+ """Lightweight Bencoding encoder/decoder for BitTorrent metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Tuple
6
+
7
+
8
+ def bdecode(data: bytes) -> Any:
9
+ """Decodes bencoded bytes into Python types."""
10
+ val, _ = _bdecode_part(data, 0)
11
+ return val
12
+
13
+
14
+ def _bdecode_part(data: bytes, idx: int) -> Tuple[Any, int]:
15
+ char = data[idx : idx + 1]
16
+
17
+ if char == b"i":
18
+ end = data.index(b"e", idx)
19
+ return int(data[idx + 1 : end]), end + 1
20
+
21
+ if char == b"l":
22
+ items = []
23
+ curr = idx + 1
24
+ while data[curr : curr + 1] != b"e":
25
+ item, curr = _bdecode_part(data, curr)
26
+ items.append(item)
27
+ return items, curr + 1
28
+
29
+ if char == b"d":
30
+ mapping = {}
31
+ curr = idx + 1
32
+ while data[curr : curr + 1] != b"e":
33
+ key, curr = _bdecode_part(data, curr)
34
+ val, curr = _bdecode_part(data, curr)
35
+ mapping[key.decode("latin1")] = val
36
+ return mapping, curr + 1
37
+
38
+ # String / Bytes: <length>:<bytes>
39
+ colon = data.index(b":", idx)
40
+ length = int(data[idx:colon])
41
+ start = colon + 1
42
+ end = start + length
43
+ return data[start:end], end
44
+
45
+
46
+ def bencode(val: Any) -> bytes:
47
+ """Encodes Python types into bencoded bytes."""
48
+ if isinstance(val, int):
49
+ return f"i{val}e".encode("ascii")
50
+ if isinstance(val, (bytes, bytearray)):
51
+ return f"{len(val)}:".encode("ascii") + bytes(val)
52
+ if isinstance(val, str):
53
+ encoded = val.encode("utf-8")
54
+ return f"{len(encoded)}:".encode("ascii") + encoded
55
+ if isinstance(val, list):
56
+ return b"l" + b"".join(bencode(i) for i in val) + b"e"
57
+ if isinstance(val, dict):
58
+ out = b"d"
59
+ for k in sorted(val.keys()):
60
+ out += bencode(k) + bencode(val[k])
61
+ return out + b"e"
62
+ raise TypeError(f"Cannot bencode type: {type(val)}")