bittorrent-client 0.1.0__tar.gz

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 (28) hide show
  1. bittorrent_client-0.1.0/PKG-INFO +66 -0
  2. bittorrent_client-0.1.0/README.md +48 -0
  3. bittorrent_client-0.1.0/bittorrent/__init__.py +0 -0
  4. bittorrent_client-0.1.0/bittorrent/__main__.py +4 -0
  5. bittorrent_client-0.1.0/bittorrent/bencode.py +144 -0
  6. bittorrent_client-0.1.0/bittorrent/cli.py +57 -0
  7. bittorrent_client-0.1.0/bittorrent/client.py +222 -0
  8. bittorrent_client-0.1.0/bittorrent/ip.py +18 -0
  9. bittorrent_client-0.1.0/bittorrent/peer.py +500 -0
  10. bittorrent_client-0.1.0/bittorrent/piece_manager.py +210 -0
  11. bittorrent_client-0.1.0/bittorrent/storage.py +123 -0
  12. bittorrent_client-0.1.0/bittorrent/torrent.py +209 -0
  13. bittorrent_client-0.1.0/bittorrent/trackers.py +318 -0
  14. bittorrent_client-0.1.0/bittorrent_client.egg-info/PKG-INFO +66 -0
  15. bittorrent_client-0.1.0/bittorrent_client.egg-info/SOURCES.txt +26 -0
  16. bittorrent_client-0.1.0/bittorrent_client.egg-info/dependency_links.txt +1 -0
  17. bittorrent_client-0.1.0/bittorrent_client.egg-info/entry_points.txt +2 -0
  18. bittorrent_client-0.1.0/bittorrent_client.egg-info/top_level.txt +1 -0
  19. bittorrent_client-0.1.0/pyproject.toml +33 -0
  20. bittorrent_client-0.1.0/setup.cfg +4 -0
  21. bittorrent_client-0.1.0/test/test_async_peer.py +67 -0
  22. bittorrent_client-0.1.0/test/test_bencode.py +115 -0
  23. bittorrent_client-0.1.0/test/test_client_assembly.py +124 -0
  24. bittorrent_client-0.1.0/test/test_peer.py +71 -0
  25. bittorrent_client-0.1.0/test/test_piece_manager.py +112 -0
  26. bittorrent_client-0.1.0/test/test_storage.py +112 -0
  27. bittorrent_client-0.1.0/test/test_torrent.py +122 -0
  28. bittorrent_client-0.1.0/test/test_trackers.py +67 -0
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: bittorrent_client
3
+ Version: 0.1.0
4
+ Summary: A lightweight, robust BitTorrent client built with pure Python standard library.
5
+ Author: Omar Aflak
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Communications :: File Sharing
15
+ Classifier: Topic :: Internet
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+
19
+ # BitTorrent Client
20
+
21
+ A lightweight, robust BitTorrent client built with pure Python standard library (zero external dependencies).
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install bittorrent_client
27
+ ```
28
+
29
+ ## CLI Usage
30
+
31
+ Run directly via the command-line entry point:
32
+
33
+ ```bash
34
+ bittorrent file.torrent -o ./downloads
35
+ ```
36
+
37
+ Or run as a module:
38
+
39
+ ```bash
40
+ python3 -m bittorrent file.torrent -o ./downloads
41
+ ```
42
+
43
+ Options:
44
+ - `-o, --output`: Destination directory (default: `./`)
45
+ - `-w, --workers`: Maximum concurrent peer workers (default: `60`)
46
+ - `-v, --verbose`: Enable debug logging
47
+
48
+ ## Python API
49
+
50
+ ```python
51
+ import logging
52
+ from bittorrent.torrent import Torrent
53
+ from bittorrent.client import Client
54
+
55
+ logging.basicConfig(level=logging.INFO)
56
+
57
+ torrent = Torrent.from_file('file.torrent')
58
+ client = Client(torrent)
59
+ client.download('./downloads')
60
+ ```
61
+
62
+ ## Running Tests
63
+
64
+ ```bash
65
+ python3 -m unittest discover test
66
+ ```
@@ -0,0 +1,48 @@
1
+ # BitTorrent Client
2
+
3
+ A lightweight, robust BitTorrent client built with pure Python standard library (zero external dependencies).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install bittorrent_client
9
+ ```
10
+
11
+ ## CLI Usage
12
+
13
+ Run directly via the command-line entry point:
14
+
15
+ ```bash
16
+ bittorrent file.torrent -o ./downloads
17
+ ```
18
+
19
+ Or run as a module:
20
+
21
+ ```bash
22
+ python3 -m bittorrent file.torrent -o ./downloads
23
+ ```
24
+
25
+ Options:
26
+ - `-o, --output`: Destination directory (default: `./`)
27
+ - `-w, --workers`: Maximum concurrent peer workers (default: `60`)
28
+ - `-v, --verbose`: Enable debug logging
29
+
30
+ ## Python API
31
+
32
+ ```python
33
+ import logging
34
+ from bittorrent.torrent import Torrent
35
+ from bittorrent.client import Client
36
+
37
+ logging.basicConfig(level=logging.INFO)
38
+
39
+ torrent = Torrent.from_file('file.torrent')
40
+ client = Client(torrent)
41
+ client.download('./downloads')
42
+ ```
43
+
44
+ ## Running Tests
45
+
46
+ ```bash
47
+ python3 -m unittest discover test
48
+ ```
File without changes
@@ -0,0 +1,4 @@
1
+ from bittorrent.cli import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
@@ -0,0 +1,144 @@
1
+ from typing import Union
2
+
3
+ BenType = Union[int, bytes, list['BenType'], dict[str, 'BenType']]
4
+
5
+
6
+ def bencode(data: BenType) -> bytes:
7
+ if isinstance(data, bool):
8
+ # In Python bool is a subclass of int; bencode doesn't have bool, encode as 1 or 0
9
+ return b'i1e' if data else b'i0e'
10
+
11
+ if isinstance(data, int):
12
+ return f'i{data}e'.encode('ascii')
13
+
14
+ if isinstance(data, str):
15
+ encoded = data.encode('utf-8')
16
+ return f'{len(encoded)}:'.encode('ascii') + encoded
17
+
18
+ if isinstance(data, bytes):
19
+ return f'{len(data)}:'.encode('ascii') + data
20
+
21
+ if isinstance(data, (list, tuple)):
22
+ values = b''.join(bencode(x) for x in data)
23
+ return b'l' + values + b'e'
24
+
25
+ if isinstance(data, dict):
26
+ # BEP 3: Keys must appear in sorted order (strings are sorted as raw byte strings)
27
+ def key_bytes(k: Union[str, bytes]) -> bytes:
28
+ if isinstance(k, bytes):
29
+ return k
30
+ if isinstance(k, str):
31
+ return k.encode('utf-8')
32
+ raise ValueError(f'Dict key must be str or bytes, got {type(k)}')
33
+
34
+ sorted_items = sorted(
35
+ data.items(), key=lambda item: key_bytes(item[0]))
36
+ values = b''.join(bencode(k) + bencode(v) for k, v in sorted_items)
37
+ return b'd' + values + b'e'
38
+
39
+ raise ValueError(f'Type {type(data)} not supported.')
40
+
41
+
42
+ def decode_bencode(data: bytes) -> BenType:
43
+ if len(data) == 0:
44
+ return b''
45
+ value, end_idx = _decode_bencode(data, 0)
46
+ if end_idx != len(data):
47
+ raise ValueError(
48
+ f'Trailing data after bencode decode: decoded {end_idx} of {len(data)} bytes')
49
+ return value
50
+
51
+
52
+ def decode_bencode_with_raw(data: bytes) -> tuple[BenType, dict[str, bytes]]:
53
+ """
54
+ Decodes bencoded data and returns a tuple of:
55
+ (decoded_value, raw_slices)
56
+ where raw_slices is a dict mapping top-level dictionary string keys to their exact raw bytes in data.
57
+ """
58
+ if len(data) == 0:
59
+ return b'', {}
60
+ value, end_idx, raw_slices = _decode_bencode_with_raw_map(data, 0)
61
+ if end_idx != len(data):
62
+ raise ValueError(
63
+ f'Trailing data after bencode decode: decoded {end_idx} of {len(data)} bytes')
64
+ return value, raw_slices
65
+
66
+
67
+ def _decode_bencode(data: bytes, start: int = 0) -> tuple[BenType, int]:
68
+ val, end_idx, _ = _decode_bencode_with_raw_map(data, start)
69
+ return val, end_idx
70
+
71
+
72
+ def _decode_bencode_with_raw_map(data: bytes, start: int = 0) -> tuple[BenType, int, dict[str, bytes]]:
73
+ if start >= len(data):
74
+ return b'', start, {}
75
+
76
+ char = chr(data[start])
77
+
78
+ if char.isdigit():
79
+ idx = data.find(b':', start)
80
+ if idx == -1:
81
+ raise ValueError(
82
+ f'Missing colon delimiter for string length at offset {start}')
83
+ try:
84
+ length = int(data[start:idx])
85
+ except ValueError as e:
86
+ raise ValueError(f'Invalid string length at offset {start}: {e}')
87
+ if length < 0:
88
+ raise ValueError(
89
+ f'Negative string length at offset {start}: {length}')
90
+ str_start = idx + 1
91
+ str_end = str_start + length
92
+ if str_end > len(data):
93
+ raise ValueError(
94
+ f'Unexpected EOF: expected {length} bytes at {str_start}, available {len(data) - str_start}')
95
+ value = data[str_start:str_end]
96
+ return value, str_end, {}
97
+
98
+ if char == 'i':
99
+ idx = data.find(b'e', start)
100
+ if idx == -1:
101
+ raise ValueError(
102
+ f'Missing end delimiter "e" for integer at offset {start}')
103
+ raw_int = data[start + 1:idx]
104
+ if len(raw_int) == 0:
105
+ # For backward compatibility with tests expecting empty int 'ie' -> 0
106
+ return 0, idx + 1, {}
107
+ try:
108
+ value = int(raw_int)
109
+ except ValueError as e:
110
+ raise ValueError(f'Invalid integer at offset {start}: {e}')
111
+ return value, idx + 1, {}
112
+
113
+ if char == 'l':
114
+ _list: list[BenType] = []
115
+ pos = start + 1
116
+ while pos < len(data) and chr(data[pos]) != 'e':
117
+ val, pos = _decode_bencode(data, pos)
118
+ _list.append(val)
119
+ if pos >= len(data):
120
+ raise ValueError(f'Unterminated list starting at offset {start}')
121
+ return _list, pos + 1, {}
122
+
123
+ if char == 'd':
124
+ _dict: dict[str, BenType] = {}
125
+ raw_slices: dict[str, bytes] = {}
126
+ pos = start + 1
127
+ while pos < len(data) and chr(data[pos]) != 'e':
128
+ val_key, pos = _decode_bencode(data, pos)
129
+ if not isinstance(val_key, bytes):
130
+ raise ValueError(
131
+ f'Dict key at offset {pos} must be bytes, got {type(val_key)}')
132
+ key_str = val_key.decode('utf-8', errors='replace')
133
+ val_start = pos
134
+ val, pos = _decode_bencode(data, pos)
135
+ _dict[key_str] = val
136
+ raw_slices[key_str] = data[val_start:pos]
137
+
138
+ if pos >= len(data):
139
+ raise ValueError(
140
+ f'Unterminated dictionary starting at offset {start}')
141
+ return _dict, pos + 1, raw_slices
142
+
143
+ raise ValueError(
144
+ f'Character "{char}" at offset {start} not recognized in bencode.')
@@ -0,0 +1,57 @@
1
+ import sys
2
+ import logging
3
+ import argparse
4
+ from bittorrent.torrent import Torrent
5
+ from bittorrent.client import Client
6
+
7
+
8
+ def _format_bytes(size: int) -> str:
9
+ units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
10
+ val = float(size)
11
+ for unit in units:
12
+ if val < 1024.0 or unit == units[-1]:
13
+ return f'{val:.1f} {unit}' if unit != 'B' else f'{int(val)} B'
14
+ val /= 1024.0
15
+ return f'{val:.1f} PiB'
16
+
17
+
18
+ def main():
19
+ parser = argparse.ArgumentParser(
20
+ description='A simple, robust BitTorrent client.')
21
+ parser.add_argument('file', help='Path to .torrent file')
22
+ parser.add_argument('-o', '--output', default='./',
23
+ help='Output directory for downloaded files (default: ./)')
24
+ parser.add_argument('-w', '--workers', type=int, default=60,
25
+ help='Maximum peer worker threads (default: 60)')
26
+ parser.add_argument('-v', '--verbose', action='store_true',
27
+ help='Enable verbose debug logging')
28
+
29
+ args = parser.parse_args()
30
+
31
+ log_level = logging.DEBUG if args.verbose else logging.INFO
32
+ logging.basicConfig(
33
+ level=log_level,
34
+ format='%(asctime)s [%(levelname)s] %(message)s',
35
+ datefmt='%H:%M:%S'
36
+ )
37
+
38
+ try:
39
+ torrent = Torrent.from_file(args.file)
40
+ logging.info(
41
+ f'Loaded torrent: "{torrent.name}" ({_format_bytes(torrent.size)})')
42
+ logging.info(
43
+ f'Piece count: {torrent.piece_count} (piece size: {_format_bytes(torrent.piece_size)})')
44
+ logging.info(f'Trackers: {len(torrent.trackers)} found')
45
+
46
+ client = Client(torrent, max_peer_workers=args.workers)
47
+ client.download(args.output)
48
+ except KeyboardInterrupt:
49
+ logging.info('\nDownload cancelled by user.')
50
+ sys.exit(0)
51
+ except Exception as e:
52
+ logging.error(f'Error: {e}')
53
+ sys.exit(1)
54
+
55
+
56
+ if __name__ == '__main__':
57
+ main()
@@ -0,0 +1,222 @@
1
+ import os
2
+ import shutil
3
+ import random
4
+ import logging
5
+ import asyncio
6
+ from bittorrent.trackers import Trackers
7
+ from bittorrent.torrent import Torrent, File
8
+ from bittorrent.peer import AsyncPeer
9
+ from bittorrent.storage import Storage
10
+ from bittorrent.piece_manager import PieceManager
11
+
12
+
13
+ class Client:
14
+ """
15
+ BitTorrent client responsible for coordinating peer discovery, concurrent
16
+ piece downloading, piece verification, and direct-to-disk file writing.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ torrent: Torrent,
22
+ max_peer_workers: int = 60,
23
+ max_tracker_workers: int = 20,
24
+ max_peers_per_tracker: int = 200,
25
+ max_peers_per_piece: int = 2,
26
+ max_peer_batch_requests: int = 16,
27
+ piece_chunk_size: int = 16384
28
+ ):
29
+ """
30
+ Initialize a BitTorrent client instance.
31
+
32
+ :param torrent: Parsed Torrent metadata object containing file and tracker details.
33
+ :param max_peer_workers: Maximum number of concurrent peer connection workers (default: 60).
34
+ :param max_tracker_workers: Maximum number of concurrent tracker query threads (default: 20).
35
+ :param max_peers_per_tracker: Maximum number of peers to request per tracker announce (default: 200).
36
+ :param max_peers_per_piece: Maximum number of concurrent workers allowed per piece during endgame mode (default: 2).
37
+ :param max_peer_batch_requests: Initial pipeline depth for concurrent in-flight chunk requests per peer (default: 16).
38
+ :param piece_chunk_size: Size in bytes of each chunk requested from a peer (default: 16384 / 16 KiB).
39
+ """
40
+ self.torrent = torrent
41
+ self.max_peer_workers = max_peer_workers
42
+ self.max_tracker_workers = max_tracker_workers
43
+ self.max_peers_per_tracker = max_peers_per_tracker
44
+ self.max_peers_per_piece = max_peers_per_piece
45
+ self.max_peer_batch_requests = max_peer_batch_requests
46
+ self.piece_chunk_size = piece_chunk_size
47
+
48
+ self.peer_id = b'-AG0001-' + random.randbytes(12)
49
+ self.stopped = False
50
+ self.tmp = ''
51
+
52
+ def download(self, output_directory: str):
53
+ """
54
+ Download all pieces of the torrent into the target directory.
55
+
56
+ Discovers peers from configured trackers (both UDP and HTTP/HTTPS), connects
57
+ to peers concurrently with adaptive pipelining, schedules chunks using
58
+ block-level sharing and Rarest-First, and writes verified piece data directly
59
+ to target files on disk. Resumes from existing verified files without overhead.
60
+
61
+ :param output_directory: File system directory path where downloaded files will be saved.
62
+ """
63
+ if self.torrent.size == 0 or self.torrent.piece_count == 0:
64
+ logging.info('Empty torrent, creating empty files.')
65
+ Storage(self.torrent, output_directory)
66
+ return
67
+
68
+ try:
69
+ asyncio.run(self._async_download(output_directory))
70
+ except KeyboardInterrupt:
71
+ logging.warning('Download interrupted by user.')
72
+
73
+ async def _async_download(self, output_directory: str):
74
+ # 1. Direct-to-Disk Storage (pre-allocates sparse files, eliminates .tmp copy)
75
+ storage = Storage(self.torrent, output_directory)
76
+
77
+ # 2. Check for already completed pieces on disk (instant resume)
78
+ piece_manager = PieceManager(self.torrent, storage)
79
+ verified = storage.verify_existing_pieces()
80
+ for p_idx in verified:
81
+ piece_manager.mark_piece_completed(p_idx)
82
+
83
+ if piece_manager.is_finished():
84
+ logging.info(
85
+ 'All pieces are already downloaded and verified on disk.')
86
+ return
87
+
88
+ if verified:
89
+ logging.info(
90
+ f'Resuming download: {len(verified)}/{len(self.torrent.pieces)} pieces already verified on disk.')
91
+
92
+ # 3. Discover peers from trackers
93
+ trackers = Trackers(
94
+ self.torrent,
95
+ self.peer_id,
96
+ self.max_tracker_workers,
97
+ self.max_peers_per_tracker
98
+ )
99
+ peers = trackers.get_peers()
100
+ if not peers:
101
+ logging.error('No peers discovered from trackers.')
102
+ return
103
+
104
+ logging.info(
105
+ f'Starting high-speed async download with {len(peers)} discovered peers...')
106
+
107
+ # 4. Spawn AsyncPeers with adaptive pipelining and block-level scheduling
108
+ peer_tasks = []
109
+ peers_to_connect = list(peers)[:self.max_peer_workers]
110
+ for endpoint in peers_to_connect:
111
+ ap = AsyncPeer(
112
+ endpoint,
113
+ self.torrent.info_hash,
114
+ self.peer_id,
115
+ piece_manager,
116
+ initial_pipeline=self.max_peer_batch_requests,
117
+ max_pipeline=64,
118
+ min_pipeline=4
119
+ )
120
+ peer_tasks.append(asyncio.create_task(ap.run()))
121
+
122
+ # 5. Monitor download progress
123
+ total_pieces = len(self.torrent.pieces)
124
+ last_logged_count = len(piece_manager.completed_pieces)
125
+
126
+ try:
127
+ while not piece_manager.is_finished():
128
+ if all(t.done() for t in peer_tasks):
129
+ break
130
+
131
+ await asyncio.sleep(1.0)
132
+ current_count = len(piece_manager.completed_pieces)
133
+ if current_count != last_logged_count:
134
+ last_logged_count = current_count
135
+ percent = int(100 * current_count /
136
+ total_pieces) if total_pieces > 0 else 100
137
+ downloaded_bytes = sum(
138
+ self.torrent.pieces[i].size for i in piece_manager.completed_pieces)
139
+ human_done = self._human_friendly_bytes_str(
140
+ downloaded_bytes)
141
+ human_total = self._human_friendly_bytes_str(
142
+ self.torrent.size)
143
+ logging.info(
144
+ f'Progress: {current_count}/{total_pieces} pieces ({percent}%) [{human_done} / {human_total}]')
145
+
146
+ finally:
147
+ # Clean up all peer tasks
148
+ for t in peer_tasks:
149
+ t.cancel()
150
+ await asyncio.gather(*peer_tasks, return_exceptions=True)
151
+
152
+ if not piece_manager.is_finished():
153
+ logging.error('Download did not finish completely.')
154
+ return
155
+
156
+ logging.info(
157
+ 'Download complete! All pieces verified directly on disk.')
158
+
159
+ def _assemble_files(self, output_directory: str):
160
+ """Assembles downloaded pieces from .tmp if present (backward compatibility)."""
161
+ if self.torrent.is_multi_file:
162
+ base_dir = os.path.join(output_directory, self.torrent.name)
163
+ for file_info in self.torrent.files:
164
+ file_dir = os.path.join(base_dir, *file_info.path[:-1])
165
+ os.makedirs(file_dir, exist_ok=True)
166
+ target_path = os.path.join(file_dir, file_info.path[-1])
167
+ self._assemble_file(file_info, target_path)
168
+ else:
169
+ os.makedirs(output_directory, exist_ok=True)
170
+ if self.torrent.files:
171
+ file_info = self.torrent.files[0]
172
+ target_path = os.path.join(
173
+ output_directory, file_info.path[-1])
174
+ self._assemble_file(file_info, target_path)
175
+
176
+ if self.tmp and os.path.isdir(self.tmp):
177
+ try:
178
+ shutil.rmtree(self.tmp)
179
+ except Exception:
180
+ pass
181
+
182
+ def _assemble_file(self, file_info: File, output_path: str):
183
+ """Assembles a single file from .tmp part files (backward compatibility)."""
184
+ if file_info.size == 0:
185
+ with open(output_path, 'wb'):
186
+ pass
187
+ return
188
+
189
+ piece_size = self.torrent.piece_size
190
+ file_start = file_info.start
191
+ file_end = file_start + file_info.size
192
+ start_piece_index = file_start // piece_size if piece_size > 0 else 0
193
+
194
+ with open(output_path, 'wb') as dest:
195
+ for piece in self.torrent.pieces[start_piece_index:]:
196
+ piece_start = piece.index * piece_size
197
+ piece_end = piece_start + piece.size
198
+
199
+ if piece_start >= file_end:
200
+ break
201
+
202
+ overlap_start = max(piece_start, file_start)
203
+ overlap_end = min(piece_end, file_end)
204
+
205
+ if overlap_end > overlap_start:
206
+ part_path = os.path.join(
207
+ self.tmp, f'piece_{piece.index}.part')
208
+ if os.path.exists(part_path):
209
+ with open(part_path, 'rb') as part_file:
210
+ part_file.seek(overlap_start - piece_start)
211
+ data = part_file.read(overlap_end - overlap_start)
212
+ dest.write(data)
213
+
214
+ @staticmethod
215
+ def _human_friendly_bytes_str(size: int) -> str:
216
+ units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
217
+ val = float(size)
218
+ for unit in units:
219
+ if val < 1024.0 or unit == units[-1]:
220
+ return f'{val:.1f} {unit}' if unit != 'B' else f'{int(val)} B'
221
+ val /= 1024.0
222
+ return f'{val:.1f} PiB'
@@ -0,0 +1,18 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class IpAndPort:
6
+ ip: str
7
+ port: int
8
+
9
+ def __hash__(self) -> int:
10
+ return hash((self.ip, self.port))
11
+
12
+ def __eq__(self, other: object) -> bool:
13
+ if not isinstance(other, IpAndPort):
14
+ return False
15
+ return self.ip == other.ip and self.port == other.port
16
+
17
+ def __str__(self) -> str:
18
+ return f'{self.ip}:{self.port}'