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/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Plexo CLI: Multi-network bandwidth bonding download accelerator."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "Jagjot Singh"
plexo/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """Command Line Interface for Plexo.
2
+
3
+ Exposes subcommands: download, adapters, proxy, info.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ # Ensure UTF-8 output on Windows consoles
14
+ if sys.platform == "win32":
15
+ try:
16
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
17
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
18
+ except Exception:
19
+ pass
20
+
21
+ import click
22
+ from rich.console import Console
23
+ from rich.panel import Panel
24
+ from rich.table import Table
25
+ from . import __version__
26
+ from .core.adapter import discover_adapters, get_bonded_pool
27
+ from .core.engine import BondingDownloadEngine, ProgressUpdate
28
+ from .proxy.server import run_proxy
29
+ from .ui.dashboard import TerminalDashboard
30
+
31
+ console = Console()
32
+
33
+
34
+ @click.group()
35
+ @click.version_option(version=__version__, prog_name="plexo")
36
+ def main():
37
+ """Plexo: Multi-Network Bandwidth Bonding Download Engine."""
38
+ pass
39
+
40
+
41
+ @main.command(name="adapters")
42
+ @click.option("--all", "show_all", is_flag=True, help="Show all adapters including offline/internal.")
43
+ def list_adapters(show_all: bool):
44
+ """Scan and benchmark active network adapters."""
45
+ console.print("\n[bold cyan]Scanning Network Interfaces...[/bold cyan]")
46
+ adapters = discover_adapters(verify_online=True)
47
+
48
+ table = Table(title="[bold green]Network Interfaces[/bold green]")
49
+ table.add_column("Interface Name", style="bold white")
50
+ table.add_column("Type", style="magenta")
51
+ table.add_column("IPv4 Address", style="cyan")
52
+ table.add_column("Link Speed", style="yellow")
53
+ table.add_column("Internet Route", style="bold")
54
+
55
+ display_list = adapters if show_all else [a for a in adapters if a.is_online]
56
+
57
+ for a in display_list:
58
+ status_text = "[bold green]ONLINE[/bold green]" if a.is_online else "[dim red]OFFLINE[/dim red]"
59
+ speed_str = f"{a.speed_mbps} Mbps" if a.speed_mbps > 0 else "Unknown"
60
+ table.add_row(a.name, a.kind.upper(), a.ip_address, speed_str, status_text)
61
+
62
+ console.print(table)
63
+ online_count = sum(1 for a in adapters if a.is_online)
64
+
65
+ if online_count >= 2:
66
+ console.print(
67
+ f"\n[bold green][OK] Ready for bonding:[/bold green] [yellow]{online_count}[/yellow] independent connections detected.\n"
68
+ )
69
+ elif online_count == 1:
70
+ console.print(
71
+ "\n[yellow][INFO] 1 connection active.[/yellow] Plug in Phone 5G USB tethering or Ethernet to enable multi-network bonding.\n"
72
+ )
73
+ else:
74
+ console.print("\n[bold red][X] No online connections found.[/bold red]\n")
75
+
76
+
77
+ @main.command(name="download")
78
+ @click.argument("url")
79
+ @click.option("-o", "--output", "output_path", type=click.Path(), default=None, help="Output destination path.")
80
+ @click.option("--chunk-size", default=8, type=int, help="Chunk size in MB (default: 8 MB).")
81
+ @click.option("--workers", default=2, type=int, help="Workers per network adapter (default: 2).")
82
+ def download_file(url: str, output_path: Optional[str], chunk_size: int, workers: int):
83
+ """Download a file using bonded multi-network connections."""
84
+ adapters = get_bonded_pool()
85
+ if not adapters:
86
+ console.print("[bold red]Error: No active online network adapters detected.[/bold red]")
87
+ raise click.Abort()
88
+
89
+ dashboard = TerminalDashboard(console=console)
90
+ chunk_bytes = chunk_size * 1024 * 1024
91
+
92
+ engine = BondingDownloadEngine(
93
+ url=url,
94
+ output_path=output_path,
95
+ adapters=adapters,
96
+ chunk_size=chunk_bytes,
97
+ workers_per_adapter=workers,
98
+ )
99
+
100
+ probe = engine._prepare()
101
+ dashboard.setup(probe.filename, probe.total_bytes, adapters)
102
+
103
+ start_time = time.time()
104
+ with dashboard.progress:
105
+ final_path = engine.execute(progress_callback=dashboard.update)
106
+
107
+ elapsed = time.time() - start_time
108
+ total_bytes = final_path.stat().st_size
109
+
110
+ # Calculate checksum
111
+ from .core.sparse import SparseFileWriter
112
+ with SparseFileWriter(final_path, total_bytes) as writer:
113
+ checksum = writer.calculate_checksum()
114
+
115
+ dashboard.finish(final_path.name, total_bytes, elapsed, checksum)
116
+
117
+
118
+ @main.command(name="proxy")
119
+ @click.option("--host", default="127.0.0.1", help="Proxy listening host (default: 127.0.0.1).")
120
+ @click.option("--port", default=8899, type=int, help="Proxy listening port (default: 8899).")
121
+ def start_proxy(host: str, port: int):
122
+ """Launch local acceleration proxy for pip, git, and curl."""
123
+ run_proxy(host=host, port=port)
124
+
125
+
126
+ @main.command(name="torrent")
127
+ @click.argument("source")
128
+ @click.option("-o", "--output-dir", default=".", help="Directory to save downloaded files.")
129
+ def download_torrent(source: str, output_dir: str):
130
+ """Download a torrent or magnet link across bonded network adapters."""
131
+ from .torrent.metadata import parse_magnet, parse_torrent_file
132
+ from .torrent.client import TorrentBondingEngine
133
+
134
+ adapters = get_bonded_pool()
135
+ if not adapters:
136
+ console.print("[bold red]Error: No active online network adapters detected.[/bold red]")
137
+ raise click.Abort()
138
+
139
+ if source.startswith("magnet:?"):
140
+ meta = parse_magnet(source)
141
+ else:
142
+ meta = parse_torrent_file(source)
143
+
144
+ console.print(
145
+ Panel.fit(
146
+ f"[bold cyan]Plexo Torrent Swarm Engine[/bold cyan]\n"
147
+ f"Name: [bold yellow]{meta.name}[/bold yellow]\n"
148
+ f"Info Hash: [dim]{meta.info_hash_hex}[/dim]\n"
149
+ f"Active Network Links: [bold green]{len(adapters)}[/bold green]",
150
+ border_style="cyan",
151
+ )
152
+ )
153
+
154
+ dashboard = TerminalDashboard(console=console)
155
+ dashboard.setup(meta.name, meta.total_length, adapters)
156
+
157
+ engine = TorrentBondingEngine(metadata=meta, output_dir=output_dir, adapters=adapters)
158
+
159
+ start_time = time.time()
160
+ with dashboard.progress:
161
+ final_path = engine.download(progress_callback=dashboard.update)
162
+
163
+ elapsed = time.time() - start_time
164
+ total_bytes = final_path.stat().st_size
165
+
166
+ from .core.sparse import SparseFileWriter
167
+ with SparseFileWriter(final_path, total_bytes) as writer:
168
+ checksum = writer.calculate_checksum()
169
+
170
+ dashboard.finish(final_path.name, total_bytes, elapsed, checksum)
171
+
172
+
173
+ @main.command(name="info")
174
+ def show_info():
175
+ """Display Plexo environment and bonding capabilities."""
176
+ adapters = get_bonded_pool()
177
+ console.print(
178
+ Panel.fit(
179
+ f"[bold cyan]Plexo CLI v{__version__}[/bold cyan]\n"
180
+ f"Bonding Engine: [bold green]Active[/bold green]\n"
181
+ f"Verified Online Adapters: [bold yellow]{len(adapters)}[/bold yellow]\n"
182
+ f"Default Proxy Port: [bold magenta]8899[/bold magenta]",
183
+ border_style="cyan",
184
+ title="System Capabilities",
185
+ )
186
+ )
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
plexo/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core multi-adapter bonding and download engine."""
plexo/core/adapter.py ADDED
@@ -0,0 +1,139 @@
1
+ """Network interface discovery and binding module.
2
+
3
+ Detects active network adapters (Wi-Fi, USB cellular tether, Ethernet),
4
+ filters internal/virtual networks, and verifies outward internet reachability.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import platform
11
+ import socket
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from typing import List, Optional
15
+ import psutil
16
+ import urllib3
17
+
18
+
19
+ @dataclass
20
+ class NetworkAdapter:
21
+ """Represents a discovered physical or virtual network interface."""
22
+ name: str
23
+ ip_address: str
24
+ netmask: str
25
+ kind: str # "wifi", "usb", "ethernet", "other"
26
+ is_up: bool
27
+ speed_mbps: int = 0
28
+ gateway: Optional[str] = None
29
+ is_online: bool = False
30
+
31
+ @property
32
+ def display_name(self) -> str:
33
+ kind_label = self.kind.upper()
34
+ return f"{self.name} [{kind_label}] ({self.ip_address})"
35
+
36
+
37
+ def classify_interface(name: str) -> str:
38
+ """Classifies interface type based on OS naming conventions."""
39
+ name_lower = name.lower()
40
+ if any(k in name_lower for k in ("wi-fi", "wifi", "wireless", "wlan", "802.11")):
41
+ return "wifi"
42
+ if any(k in name_lower for k in ("rndis", "tether", "remote ndis", "apple mobile", "usb", "enx")):
43
+ return "usb"
44
+ if any(k in name_lower for k in ("ethernet", "lan", "eth", "en0", "en1")):
45
+ return "ethernet"
46
+ return "other"
47
+
48
+
49
+ def is_usable_ip(ip_str: str) -> bool:
50
+ """Checks if an IPv4 address is routable and not loopback/link-local."""
51
+ try:
52
+ ip = ipaddress.IPv4Address(ip_str)
53
+ if ip.is_loopback:
54
+ return False
55
+ if ip.is_link_local: # 169.254.x.x
56
+ return False
57
+ if ip.is_unspecified: # 0.0.0.0
58
+ return False
59
+ return True
60
+ except (ValueError, ipaddress.AddressValueError):
61
+ return False
62
+
63
+
64
+ def test_adapter_connectivity(ip_address: str, timeout: float = 2.0) -> bool:
65
+ """Verifies that an outgoing connection bound to this source IP can reach the internet."""
66
+ test_hosts = [("1.1.1.1", 53), ("8.8.8.8", 53)]
67
+ for host, port in test_hosts:
68
+ sock = None
69
+ try:
70
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
71
+ sock.settimeout(timeout)
72
+ # Bind specifically to this adapter's source IP
73
+ sock.bind((ip_address, 0))
74
+ sock.connect((host, port))
75
+ return True
76
+ except (socket.error, OSError):
77
+ continue
78
+ finally:
79
+ if sock:
80
+ sock.close()
81
+ return False
82
+
83
+
84
+ def discover_adapters(verify_online: bool = True) -> List[NetworkAdapter]:
85
+ """Discovers all active, routable network adapters."""
86
+ adapters: List[NetworkAdapter] = []
87
+ seen_ips = set()
88
+
89
+ if_addrs = psutil.net_if_addrs()
90
+ if_stats = psutil.net_if_stats()
91
+
92
+ # Exclude common internal virtual switches unless they provide genuine routes
93
+ virtual_keywords = ("vEthernet", "Hyper-V", "VirtualBox", "VMware", "Loopback", "WSL")
94
+
95
+ for iface_name, addrs in if_addrs.items():
96
+ if any(v.lower() in iface_name.lower() for v in virtual_keywords):
97
+ continue
98
+
99
+ stats = if_stats.get(iface_name)
100
+ is_up = stats.isup if stats else True
101
+ if not is_up:
102
+ continue
103
+
104
+ for addr in addrs:
105
+ # We focus on IPv4 for multi-adapter bonding
106
+ if addr.family == socket.AF_INET:
107
+ ip = addr.address
108
+ if ip in seen_ips or not is_usable_ip(ip):
109
+ continue
110
+
111
+ seen_ips.add(ip)
112
+ kind = classify_interface(iface_name)
113
+ speed = stats.speed if stats else 0
114
+
115
+ online = False
116
+ if verify_online:
117
+ online = test_adapter_connectivity(ip)
118
+
119
+ adapter = NetworkAdapter(
120
+ name=iface_name,
121
+ ip_address=ip,
122
+ netmask=addr.netmask or "255.255.255.0",
123
+ kind=kind,
124
+ is_up=is_up,
125
+ speed_mbps=speed,
126
+ is_online=online if verify_online else is_up,
127
+ )
128
+ adapters.append(adapter)
129
+
130
+ # Sort so online adapters appear first, prioritized by speed/type
131
+ adapters.sort(key=lambda a: (not a.is_online, a.kind == "other", a.name))
132
+ return adapters
133
+
134
+
135
+ def get_bonded_pool(min_adapters: int = 1) -> List[NetworkAdapter]:
136
+ """Returns all verified online adapters ready for bonding."""
137
+ adapters = discover_adapters(verify_online=True)
138
+ online = [a for a in adapters if a.is_online]
139
+ return online if online else adapters
plexo/core/client.py ADDED
@@ -0,0 +1,148 @@
1
+ """HTTP probe and socket-bound range request client.
2
+
3
+ Inspects remote servers for Range support and executes chunk transfers
4
+ strictly bound to designated local adapter IP addresses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from dataclasses import dataclass
12
+ from typing import Callable, Generator, Optional
13
+ from urllib.parse import unquote, urlparse
14
+ import urllib3
15
+ from urllib3.util import parse_url
16
+
17
+
18
+ @dataclass
19
+ class ProbeResult:
20
+ """Metadata retrieved from probing the target URL."""
21
+ url: str
22
+ total_bytes: int
23
+ accepts_ranges: bool
24
+ filename: str
25
+ content_type: str
26
+ etag: Optional[str] = None
27
+
28
+
29
+ def extract_filename(url: str, content_disposition: Optional[str] = None) -> str:
30
+ """Deduces filename from Content-Disposition header or URL path."""
31
+ if content_disposition:
32
+ # Match filename="example.zip" or filename*=UTF-8''example.zip
33
+ match = re.search(r'filename\*?=(?:UTF-8\'\')?["\']?([^"\';]+)["\']?', content_disposition, re.IGNORECASE)
34
+ if match:
35
+ return os.path.basename(unquote(match.group(1).strip()))
36
+
37
+ parsed = urlparse(url)
38
+ path_name = os.path.basename(unquote(parsed.path))
39
+ if path_name and "." in path_name:
40
+ return path_name
41
+
42
+ return "downloaded_file.bin"
43
+
44
+
45
+ def probe_url(url: str, timeout: float = 10.0) -> ProbeResult:
46
+ """Probes the remote server to determine file size, range support, and filename."""
47
+ http = urllib3.PoolManager()
48
+ headers = {
49
+ "User-Agent": "Plexo-CLI/0.1.0 (Bandwidth-Bonding-Engine)",
50
+ "Accept-Encoding": "identity",
51
+ }
52
+
53
+ try:
54
+ # First attempt with HEAD
55
+ resp = http.request("HEAD", url, headers=headers, timeout=timeout, redirect=True)
56
+ if resp.status >= 400:
57
+ # Fallback to GET with 1 byte range if HEAD is blocked
58
+ headers["Range"] = "bytes=0-0"
59
+ resp = http.request("GET", url, headers=headers, timeout=timeout, redirect=True)
60
+ except Exception:
61
+ # Fallback to GET with range
62
+ headers["Range"] = "bytes=0-0"
63
+ resp = http.request("GET", url, headers=headers, timeout=timeout, redirect=True)
64
+
65
+ final_url = url
66
+ if hasattr(resp, "url") and resp.url:
67
+ candidate_url = resp.url
68
+ if candidate_url.startswith("http://") or candidate_url.startswith("https://"):
69
+ final_url = candidate_url
70
+ headers_resp = resp.headers
71
+
72
+ # Check range support
73
+ accepts_ranges = False
74
+ if resp.status == 206:
75
+ accepts_ranges = True
76
+ elif headers_resp.get("Accept-Ranges", "").lower() == "bytes":
77
+ accepts_ranges = True
78
+
79
+ # Determine total size
80
+ total_bytes = 0
81
+ content_range = headers_resp.get("Content-Range")
82
+ if content_range:
83
+ # format: bytes 0-0/104857600
84
+ match = re.search(r"/(\d+)", content_range)
85
+ if match:
86
+ total_bytes = int(match.group(1))
87
+ accepts_ranges = True
88
+
89
+ if not total_bytes:
90
+ cl = headers_resp.get("Content-Length")
91
+ if cl and cl.isdigit():
92
+ total_bytes = int(cl)
93
+
94
+ cd = headers_resp.get("Content-Disposition")
95
+ filename = extract_filename(final_url, cd)
96
+ content_type = headers_resp.get("Content-Type", "application/octet-stream")
97
+ etag = headers_resp.get("ETag")
98
+
99
+ return ProbeResult(
100
+ url=final_url,
101
+ total_bytes=total_bytes,
102
+ accepts_ranges=accepts_ranges,
103
+ filename=filename,
104
+ content_type=content_type,
105
+ etag=etag,
106
+ )
107
+
108
+
109
+ class BoundRangeClient:
110
+ """Performs HTTP GET range downloads strictly bound to a local adapter IP."""
111
+
112
+ def __init__(self, local_ip: str, timeout: float = 15.0):
113
+ self.local_ip = local_ip
114
+ self.timeout = timeout
115
+ # Bind urllib3 socket connections to (local_ip, 0)
116
+ self._pool = urllib3.PoolManager(
117
+ source_address=(local_ip, 0),
118
+ timeout=urllib3.Timeout(connect=timeout, read=timeout),
119
+ retries=urllib3.Retry(total=3, backoff_factor=0.5),
120
+ )
121
+
122
+ def fetch_range(
123
+ self,
124
+ url: str,
125
+ start_byte: int,
126
+ end_byte: int,
127
+ chunk_size: int = 64 * 1024, # 64 KB read buffer
128
+ on_bytes: Optional[Callable[[int], None]] = None,
129
+ ) -> Generator[bytes, None, None]:
130
+ """Fetches [start_byte, end_byte] inclusive through the bound adapter."""
131
+ headers = {
132
+ "User-Agent": "Plexo-CLI/0.1.0 (Bandwidth-Bonding-Engine)",
133
+ "Range": f"bytes={start_byte}-{end_byte}",
134
+ "Accept-Encoding": "identity",
135
+ }
136
+
137
+ resp = self._pool.request("GET", url, headers=headers, preload_content=False)
138
+ try:
139
+ if resp.status not in (200, 206):
140
+ raise IOError(f"HTTP Range request failed with status {resp.status}")
141
+
142
+ for data in resp.stream(chunk_size):
143
+ if data:
144
+ if on_bytes:
145
+ on_bytes(len(data))
146
+ yield data
147
+ finally:
148
+ resp.release_conn()