netpath 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.
netpath/globe.py ADDED
@@ -0,0 +1,205 @@
1
+ import ipaddress
2
+ import json
3
+ import tempfile
4
+ import webbrowser
5
+ from pathlib import Path
6
+
7
+ import requests
8
+ from rich.panel import Panel
9
+
10
+ from netpath.display import LATENCY_GREEN_MS, LATENCY_YELLOW_MS, console
11
+
12
+ _BATCH_URL = "http://ip-api.com/batch"
13
+ _BATCH_SIZE = 100
14
+
15
+
16
+ def _is_private(host: str) -> bool:
17
+ try:
18
+ return ipaddress.ip_address(host).is_private
19
+ except ValueError:
20
+ return False # Hostname — let ip-api.com resolve it
21
+
22
+
23
+ def _geolocate(hosts: list[str]) -> dict[str, dict]:
24
+ """Batch geolocate hosts/IPs. Returns {host: {lat, lon}} for successful lookups."""
25
+ results: dict[str, dict] = {}
26
+ for i in range(0, len(hosts), _BATCH_SIZE):
27
+ batch = hosts[i : i + _BATCH_SIZE]
28
+ payload = [{"query": h, "fields": "query,lat,lon,status"} for h in batch]
29
+ try:
30
+ resp = requests.post(_BATCH_URL, json=payload, timeout=10)
31
+ except requests.RequestException as e:
32
+ raise RuntimeError(f"Geolocation request failed: {e}") from e
33
+ if resp.status_code == 429:
34
+ raise RuntimeError("ip-api.com rate limit reached (HTTP 429)")
35
+ if not resp.ok:
36
+ raise RuntimeError(f"ip-api.com returned HTTP {resp.status_code}")
37
+ for item in resp.json():
38
+ if item.get("status") == "success":
39
+ results[item["query"]] = {"lat": item["lat"], "lon": item["lon"]}
40
+ return results
41
+
42
+
43
+ def _arc_color(delta_ms: float) -> str:
44
+ if delta_ms < LATENCY_GREEN_MS:
45
+ return "rgba(0,255,128,0.8)"
46
+ if delta_ms < LATENCY_YELLOW_MS:
47
+ return "rgba(255,220,0,0.8)"
48
+ return "rgba(255,60,60,0.9)"
49
+
50
+
51
+ def _build_html(points: list[dict], arcs: list[dict]) -> str:
52
+ pts = json.dumps(points)
53
+ acs = json.dumps(arcs)
54
+ return f"""<!DOCTYPE html>
55
+ <html>
56
+ <head>
57
+ <meta charset="utf-8">
58
+ <title>netpath — AS path globe</title>
59
+ <style>
60
+ body{{margin:0;background:#0a0a1a;overflow:hidden}}
61
+ #g{{width:100vw;height:100vh}}
62
+ #legend{{position:fixed;top:16px;right:16px;background:rgba(0,0,0,.75);
63
+ color:#ccc;font:13px monospace;padding:12px 16px;
64
+ border-radius:8px;border:1px solid #333}}
65
+ .r{{display:flex;align-items:center;gap:8px;margin:3px 0}}
66
+ .c{{width:12px;height:12px;border-radius:50%;flex-shrink:0}}
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div id="g"></div>
71
+ <div id="legend">
72
+ <b>Latency delta per hop</b>
73
+ <div class="r"><div class="c" style="background:#00ff80"></div>&lt; 20 ms</div>
74
+ <div class="r"><div class="c" style="background:#ffdc00"></div>20–79 ms</div>
75
+ <div class="r"><div class="c" style="background:#ff3c3c"></div>≥ 80 ms</div>
76
+ </div>
77
+ <script src="https://unpkg.com/globe.gl"></script>
78
+ <script>
79
+ const G=Globe()
80
+ .globeImageUrl('https://unpkg.com/three-globe/example/img/earth-dark.jpg')
81
+ .backgroundImageUrl('https://unpkg.com/three-globe/example/img/night-sky.png')
82
+ .pointsData({pts})
83
+ .pointLat(d=>d.lat).pointLng(d=>d.lon)
84
+ .pointLabel(d=>d.label)
85
+ .pointColor(()=>'#00cfff').pointRadius(.4).pointAltitude(.01)
86
+ .arcsData({acs})
87
+ .arcStartLat(d=>d.sLat).arcStartLng(d=>d.sLon)
88
+ .arcEndLat(d=>d.eLat).arcEndLng(d=>d.eLon)
89
+ .arcColor(d=>d.color).arcAltitudeAutoScale(.4)
90
+ .arcStroke(1.5).arcDashLength(.4).arcDashGap(.2).arcDashAnimateTime(2000)
91
+ (document.getElementById('g'));
92
+ G.controls().autoRotate=true;
93
+ G.controls().autoRotateSpeed=.5;
94
+ </script>
95
+ </body>
96
+ </html>"""
97
+
98
+
99
+ def render(hubs_by_asn: dict[str, list[dict]]) -> None:
100
+ """Geolocate hops, generate a Globe.gl HTML file, and open it in the browser."""
101
+ candidates: list[str] = []
102
+ seen_hosts: set[str] = set()
103
+ for hubs in hubs_by_asn.values():
104
+ for hub in hubs:
105
+ host = hub.get("host") or ""
106
+ if not host or host == "???":
107
+ continue
108
+ if _is_private(host):
109
+ continue
110
+ if host not in seen_hosts:
111
+ seen_hosts.add(host)
112
+ candidates.append(host)
113
+
114
+ if not candidates:
115
+ console.print(
116
+ Panel(
117
+ " All hops are unresolvable or private IPs — globe skipped.",
118
+ title="[bold yellow]Globe[/bold yellow]",
119
+ border_style="yellow",
120
+ expand=False,
121
+ )
122
+ )
123
+ return
124
+
125
+ try:
126
+ geo = _geolocate(candidates)
127
+ except RuntimeError as e:
128
+ console.print(
129
+ Panel(
130
+ f" Geolocation failed: {e}\n Terminal probe results are unaffected.",
131
+ title="[bold yellow]Globe warning[/bold yellow]",
132
+ border_style="yellow",
133
+ expand=False,
134
+ )
135
+ )
136
+ return
137
+
138
+ if not geo:
139
+ console.print(
140
+ Panel(
141
+ " No hops could be geolocated — globe skipped.",
142
+ title="[bold yellow]Globe[/bold yellow]",
143
+ border_style="yellow",
144
+ expand=False,
145
+ )
146
+ )
147
+ return
148
+
149
+ points: list[dict] = []
150
+ arcs: list[dict] = []
151
+ seen_pts: set[tuple[float, float]] = set()
152
+
153
+ for asn_str, hubs in hubs_by_asn.items():
154
+ geo_hops: list[dict] = []
155
+ for hub in hubs:
156
+ host = hub.get("host") or ""
157
+ if host and geo.get(host):
158
+ geo_hops.append({
159
+ "lat": geo[host]["lat"],
160
+ "lon": geo[host]["lon"],
161
+ "count": hub.get("count", 0),
162
+ "asn": hub.get("ASN") or asn_str,
163
+ "avg_ms": float(hub.get("Avg") or 0),
164
+ })
165
+
166
+ for hop in geo_hops:
167
+ key = (hop["lat"], hop["lon"])
168
+ if key not in seen_pts:
169
+ seen_pts.add(key)
170
+ points.append({
171
+ "lat": hop["lat"],
172
+ "lon": hop["lon"],
173
+ "label": f"Hop {hop['count']} · {hop['asn']}",
174
+ })
175
+
176
+ for i in range(1, len(geo_hops)):
177
+ prev, curr = geo_hops[i - 1], geo_hops[i]
178
+ delta = curr["avg_ms"] - prev["avg_ms"]
179
+ arcs.append({
180
+ "sLat": prev["lat"],
181
+ "sLon": prev["lon"],
182
+ "eLat": curr["lat"],
183
+ "eLon": curr["lon"],
184
+ "color": _arc_color(delta),
185
+ })
186
+
187
+ if not points:
188
+ console.print(
189
+ Panel(
190
+ " No hops could be geolocated — globe skipped.",
191
+ title="[bold yellow]Globe[/bold yellow]",
192
+ border_style="yellow",
193
+ expand=False,
194
+ )
195
+ )
196
+ return
197
+
198
+ html = _build_html(points, arcs)
199
+ try:
200
+ out = Path(tempfile.mkdtemp()) / "netpath-globe.html"
201
+ out.write_text(html, encoding="utf-8")
202
+ if not webbrowser.open(out.as_uri()):
203
+ console.print(f" [dim]Globe saved — open manually: {out}[/dim]")
204
+ except Exception as e:
205
+ console.print(f" [yellow]Globe write failed: {e}[/yellow]")
netpath/iperf.py ADDED
@@ -0,0 +1,59 @@
1
+ import json
2
+ import shutil
3
+ import subprocess
4
+
5
+
6
+ def available() -> bool:
7
+ return shutil.which("iperf3") is not None
8
+
9
+
10
+ def run_bidirectional(host: str, port: int = 5201, duration: int = 5) -> tuple[dict, dict]:
11
+ """
12
+ Run iperf3 upload then download to host:port.
13
+ Returns (upload_stats, download_stats) in the format display expects.
14
+ Raises RuntimeError on failure.
15
+ """
16
+ ul_raw = _run(host, port, duration, reverse=False)
17
+ dl_raw = _run(host, port, duration, reverse=True)
18
+ return _extract(ul_raw, reverse=False), _extract(dl_raw, reverse=True)
19
+
20
+
21
+ def _run(host: str, port: int, duration: int, reverse: bool) -> dict:
22
+ cmd = ["iperf3", "-c", host, "-p", str(port), "-t", str(duration), "-J"]
23
+ if reverse:
24
+ cmd.append("-R")
25
+
26
+ try:
27
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=duration + 30)
28
+ except subprocess.TimeoutExpired:
29
+ raise RuntimeError("iperf3 timed out")
30
+
31
+ raw = r.stdout or r.stderr
32
+ try:
33
+ data = json.loads(raw)
34
+ except json.JSONDecodeError:
35
+ raise RuntimeError((r.stderr or r.stdout).strip() or "iperf3 failed (no output)")
36
+
37
+ if r.returncode != 0:
38
+ err = data.get("error", "")
39
+ raise RuntimeError(err or (r.stderr.strip() or "iperf3 exited non-zero"))
40
+
41
+ return data
42
+
43
+
44
+ def _extract(data: dict, reverse: bool) -> dict:
45
+ end = data.get("end", {})
46
+ if reverse:
47
+ s = end.get("sum_received", end.get("sum", {}))
48
+ return {
49
+ "bps": s.get("bits_per_second", 0),
50
+ "recv_bps": s.get("bits_per_second", 0),
51
+ "bytes": s.get("bytes", 0),
52
+ }
53
+ else:
54
+ s = end.get("sum_sent", end.get("sum", {}))
55
+ return {
56
+ "bps": s.get("bits_per_second", 0),
57
+ "bytes": s.get("bytes", 0),
58
+ "retransmits": s.get("retransmits"),
59
+ }
netpath/mtr.py ADDED
@@ -0,0 +1,197 @@
1
+ import json
2
+ import math
3
+ import re
4
+ import shutil
5
+ import statistics
6
+ import subprocess
7
+
8
+ from .asn import cymru_bulk_lookup
9
+
10
+
11
+ def _percentile(sorted_data: list, p: float) -> float:
12
+ """Nearest-rank percentile from a pre-sorted list. p is 0–100."""
13
+ n = len(sorted_data)
14
+ if n == 0:
15
+ return 0.0
16
+ idx = min(math.ceil(p / 100.0 * n) - 1, n - 1)
17
+ return sorted_data[max(0, idx)]
18
+
19
+
20
+ def _enrich_percentiles(hub: dict) -> None:
21
+ """Add p50, p95, p99 to a hub dict using Avg+z*StDev estimation."""
22
+ if hub.get("Loss%", 0.0) >= 100.0:
23
+ hub["p50"] = None
24
+ hub["p95"] = None
25
+ hub["p99"] = None
26
+ return
27
+ avg = hub.get("Avg", 0.0)
28
+ std = hub.get("StDev", 0.0)
29
+ hub["p50"] = round(avg, 2)
30
+ hub["p95"] = round(avg + 1.645 * std, 2)
31
+ hub["p99"] = round(avg + 2.326 * std, 2)
32
+
33
+
34
+ def available() -> bool:
35
+ return shutil.which("mtr") is not None
36
+
37
+
38
+ _SOCKET_ERR_MARKERS = ("failure to open", "operation not permitted", "permission denied")
39
+ _SUID_REFUSED = "should not run suid"
40
+
41
+
42
+ class MtrPermissionError(RuntimeError):
43
+ pass
44
+
45
+
46
+ def run(host: str, cycles: int = 10) -> list[dict]:
47
+ """
48
+ Run mtr in JSON report mode. Raises MtrPermissionError on raw socket
49
+ denial so the caller can fall back to traceroute.
50
+ """
51
+ cmd = ["mtr", "--json", "--report", f"--report-cycles={cycles}", "--aslookup", host]
52
+
53
+ try:
54
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=cycles * 4 + 30)
55
+ except subprocess.TimeoutExpired:
56
+ raise RuntimeError("mtr timed out")
57
+
58
+ if result.returncode != 0:
59
+ stderr_lower = result.stderr.strip().lower()
60
+ if _SUID_REFUSED in stderr_lower or any(m in stderr_lower for m in _SOCKET_ERR_MARKERS):
61
+ raise MtrPermissionError(result.stderr.strip())
62
+ raise RuntimeError(result.stderr.strip() or "mtr exited non-zero")
63
+
64
+ try:
65
+ data = json.loads(result.stdout)
66
+ hubs = data["report"]["hubs"]
67
+ for hub in hubs:
68
+ _enrich_percentiles(hub)
69
+ return hubs
70
+ except (json.JSONDecodeError, KeyError) as e:
71
+ raise RuntimeError(f"Failed to parse mtr output: {e}")
72
+
73
+
74
+ # ── traceroute fallback ───────────────────────────────────────────────────────
75
+
76
+ def _parse_traceroute_output(output: str) -> list[dict]:
77
+ hubs = []
78
+ for line in output.splitlines():
79
+ line = line.strip()
80
+ if not line or not line[0].isdigit():
81
+ continue
82
+
83
+ m = re.match(r'^(\d+)\s+(.*)', line)
84
+ if not m:
85
+ continue
86
+
87
+ hop_num = int(m.group(1))
88
+ rest = m.group(2).strip()
89
+
90
+ if re.match(r'^[\*\s]+$', rest):
91
+ hubs.append({
92
+ "count": hop_num, "host": "???", "ASN": "AS???",
93
+ "Loss%": 100.0, "Avg": 0.0, "Best": 0.0, "Wrst": 0.0, "StDev": 0.0,
94
+ "p50": None, "p95": None, "p99": None,
95
+ })
96
+ continue
97
+
98
+ tokens = rest.split()
99
+ host = tokens[0]
100
+
101
+ rtts = []
102
+ stars = 0
103
+ i = 1
104
+ while i < len(tokens):
105
+ if tokens[i] == "*":
106
+ stars += 1
107
+ i += 1
108
+ elif i + 1 < len(tokens) and tokens[i + 1] == "ms":
109
+ try:
110
+ rtts.append(float(tokens[i]))
111
+ except ValueError:
112
+ pass
113
+ i += 2
114
+ else:
115
+ i += 1
116
+
117
+ total = len(rtts) + stars
118
+ loss_pct = (stars / total * 100.0) if total > 0 else 0.0
119
+
120
+ if not rtts:
121
+ hubs.append({
122
+ "count": hop_num, "host": host, "ASN": "AS???",
123
+ "Loss%": 100.0, "Avg": 0.0, "Best": 0.0, "Wrst": 0.0, "StDev": 0.0,
124
+ "p50": None, "p95": None, "p99": None,
125
+ })
126
+ continue
127
+
128
+ avg = sum(rtts) / len(rtts)
129
+ sorted_rtts = sorted(rtts)
130
+ hubs.append({
131
+ "count": hop_num,
132
+ "host": host,
133
+ "ASN": "AS???",
134
+ "Loss%": loss_pct,
135
+ "Avg": round(avg, 2),
136
+ "Best": round(min(rtts), 2),
137
+ "Wrst": round(max(rtts), 2),
138
+ "StDev": round(statistics.stdev(rtts) if len(rtts) > 1 else 0.0, 2),
139
+ "p50": round(_percentile(sorted_rtts, 50), 2),
140
+ "p95": round(_percentile(sorted_rtts, 95), 2),
141
+ "p99": round(_percentile(sorted_rtts, 99), 2),
142
+ })
143
+
144
+ return hubs
145
+
146
+
147
+ def _all_stars(hubs: list[dict]) -> bool:
148
+ return bool(hubs) and all(h["host"] == "???" for h in hubs)
149
+
150
+
151
+ def _run_traceroute_cmd(host: str, tcp: bool = False) -> list[dict]:
152
+ """
153
+ Run one traceroute pass.
154
+ Parameters tuned for fast failure: 1s wait, 15 hops, 2 probes → 30s worst case.
155
+ tcp=True uses TCP SYN to port 443 (requires pcap — may fail on macOS without privs).
156
+ """
157
+ cmd = ["/usr/sbin/traceroute", "-n", "-w", "1", "-m", "15", "-q", "2"]
158
+ if tcp:
159
+ cmd += ["-P", "tcp", "-p", "443"]
160
+ cmd.append(host)
161
+
162
+ try:
163
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
164
+ except subprocess.TimeoutExpired:
165
+ raise RuntimeError("traceroute timed out")
166
+
167
+ if result.returncode != 0:
168
+ raise RuntimeError(result.stderr.strip() or "traceroute failed")
169
+
170
+ return _parse_traceroute_output(result.stdout)
171
+
172
+
173
+ def run_traceroute(host: str, probes: int = 5) -> list[dict]:
174
+ """
175
+ Fallback when mtr lacks raw socket access.
176
+ Tries UDP first; if all hops are filtered, retries with TCP SYN (port 443).
177
+ TCP requires pcap — if that also fails (common on macOS), returns the
178
+ filtered UDP result rather than raising an error.
179
+ """
180
+ hubs = _run_traceroute_cmd(host, tcp=False)
181
+
182
+ if _all_stars(hubs):
183
+ try:
184
+ tcp_hubs = _run_traceroute_cmd(host, tcp=True)
185
+ if not _all_stars(tcp_hubs):
186
+ hubs = tcp_hubs
187
+ except RuntimeError:
188
+ pass # pcap unavailable or path still filtered — keep UDP result
189
+
190
+ ips = [h["host"] for h in hubs if h["host"] != "???"]
191
+ if ips:
192
+ ip_asn = cymru_bulk_lookup(ips)
193
+ for hub in hubs:
194
+ if hub["host"] != "???":
195
+ hub["ASN"] = ip_asn.get(hub["host"], "AS???")
196
+
197
+ return hubs
netpath/rum.py ADDED
@@ -0,0 +1,55 @@
1
+ import requests
2
+
3
+ CF_API_BASE = "https://api.cloudflare.com/client/v4/radar"
4
+
5
+
6
+ def fetch_asn_quality(asn: str, token: str, date_range: str = "7d") -> dict | None:
7
+ """
8
+ Fetch Cloudflare Radar speed + latency summary for an ASN.
9
+ Token needs the radar:read permission (free Cloudflare account).
10
+ Returns a flat dict of metrics, or None on failure.
11
+ """
12
+ asn_num = asn.lstrip("ASas")
13
+ headers = {"Authorization": f"Bearer {token}"}
14
+ params = {"asn": asn_num, "dateRange": date_range}
15
+
16
+ try:
17
+ resp = requests.get(
18
+ f"{CF_API_BASE}/quality/speed/summary",
19
+ params=params,
20
+ headers=headers,
21
+ timeout=10,
22
+ )
23
+ if resp.status_code == 401:
24
+ raise ValueError("Cloudflare token invalid or missing radar:read permission")
25
+ resp.raise_for_status()
26
+ data = resp.json()
27
+ except ValueError:
28
+ raise
29
+ except Exception:
30
+ return None
31
+
32
+ if not data.get("success"):
33
+ return None
34
+
35
+ # The summary lives under result.summary_0
36
+ summary = data.get("result", {}).get("summary_0", {})
37
+ if not summary:
38
+ return None
39
+
40
+ def _f(key: str) -> float | None:
41
+ v = summary.get(key)
42
+ try:
43
+ return float(v) if v is not None else None
44
+ except (TypeError, ValueError):
45
+ return None
46
+
47
+ return {
48
+ "dl_mbps": _f("bandwidthDownload"),
49
+ "ul_mbps": _f("bandwidthUpload"),
50
+ "latency_idle": _f("latencyIdle"),
51
+ "latency_loaded": _f("latencyLoaded"),
52
+ "jitter": _f("jitter"),
53
+ "packet_loss": _f("packetLoss"),
54
+ "date_range": date_range,
55
+ }
netpath/servers.py ADDED
@@ -0,0 +1,69 @@
1
+ import requests
2
+ from .asn import resolve_hosts_parallel, cymru_bulk_lookup, normalize_asn
3
+
4
+ SERVERS_URL = "https://export.iperf3serverlist.net/listed_iperf3_servers.json"
5
+
6
+ # Module-level cache so the country command doesn't re-fetch + re-resolve for each ASN
7
+ _resolved_cache: list[dict] | None = None
8
+
9
+
10
+ def parse_port(port_str: str | None) -> int:
11
+ if not port_str:
12
+ return 5201
13
+ try:
14
+ return int(str(port_str).split("-")[0].split(",")[0].strip())
15
+ except (ValueError, AttributeError):
16
+ return 5201
17
+
18
+
19
+ def _fetch_and_resolve() -> list[dict]:
20
+ """
21
+ Fetch the server list, resolve all hostnames, and bulk-lookup ASNs.
22
+ Result is cached for the lifetime of the process.
23
+ """
24
+ global _resolved_cache
25
+ if _resolved_cache is not None:
26
+ return _resolved_cache
27
+
28
+ resp = requests.get(SERVERS_URL, timeout=15)
29
+ resp.raise_for_status()
30
+ raw = resp.json()
31
+ if isinstance(raw, dict):
32
+ for key in ("servers", "data", "results"):
33
+ if key in raw:
34
+ raw = raw[key]
35
+ break
36
+
37
+ seen: set[str] = set()
38
+ unique: list[dict] = []
39
+ for s in raw:
40
+ host = (s.get("IP/HOST") or "").strip()
41
+ if host and host not in seen:
42
+ seen.add(host)
43
+ unique.append({**s, "HOST": host})
44
+
45
+ host_to_ip = resolve_hosts_parallel([s["HOST"] for s in unique])
46
+ ip_to_asn = cymru_bulk_lookup(list(set(host_to_ip.values())))
47
+
48
+ enriched = []
49
+ for s in unique:
50
+ ip = host_to_ip.get(s["HOST"])
51
+ if not ip:
52
+ continue
53
+ enriched.append({
54
+ **s,
55
+ "ip": ip,
56
+ "asn": ip_to_asn.get(ip, "AS???"),
57
+ "port": parse_port(s.get("PORT")),
58
+ })
59
+
60
+ _resolved_cache = enriched
61
+ return enriched
62
+
63
+
64
+ def find_servers_in_asn(asn: str, max_count: int = 3) -> list[dict]:
65
+ """Return up to max_count iperf3 servers in the given ASN."""
66
+ target = normalize_asn(asn)
67
+ all_servers = _fetch_and_resolve()
68
+ found = [s for s in all_servers if s["asn"] == target]
69
+ return found[:max_count] if max_count > 0 else found
netpath/speedtest.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ HTTP-based throughput measurement — no dedicated server required.
3
+
4
+ Downloads from / uploads to speed.cloudflare.com, which has CDN nodes
5
+ peered inside every major ISP globally. The measured throughput reflects
6
+ the path from this host to the nearest Cloudflare PoP, which is typically
7
+ inside the local ISP's network.
8
+ """
9
+
10
+ import shutil
11
+ import time
12
+ import requests
13
+
14
+ CF_DOWN_URL = "https://speed.cloudflare.com/__down"
15
+ CF_UP_URL = "https://speed.cloudflare.com/__up"
16
+
17
+ # Payload sizes: ramp up to saturate the link quickly
18
+ _DOWN_BYTES = 25_000_000 # 25 MB download probe
19
+ _UP_BYTES = 10_000_000 # 10 MB upload probe
20
+ _TIMEOUT = 30 # seconds per direction
21
+
22
+
23
+ def _download(duration: int = 5) -> dict:
24
+ """Stream a download from Cloudflare and return throughput stats."""
25
+ start = time.monotonic()
26
+ total = 0
27
+ first_byte: float | None = None
28
+
29
+ with requests.get(
30
+ CF_DOWN_URL,
31
+ params={"bytes": _DOWN_BYTES},
32
+ stream=True,
33
+ timeout=_TIMEOUT,
34
+ ) as resp:
35
+ resp.raise_for_status()
36
+ deadline = start + duration
37
+ for chunk in resp.iter_content(chunk_size=65_536):
38
+ if first_byte is None:
39
+ first_byte = time.monotonic()
40
+ total += len(chunk)
41
+ if time.monotonic() >= deadline:
42
+ break
43
+
44
+ elapsed = time.monotonic() - start
45
+ ttfb_ms = (first_byte - start) * 1000 if first_byte else None
46
+ return {
47
+ "bps": (total * 8) / elapsed if elapsed > 0 else 0,
48
+ "bytes": total,
49
+ "elapsed": round(elapsed, 2),
50
+ "ttfb_ms": round(ttfb_ms, 1) if ttfb_ms else None,
51
+ }
52
+
53
+
54
+ def _upload(duration: int = 5) -> dict:
55
+ """POST a zero-filled payload to Cloudflare and return throughput stats."""
56
+ payload_size = min(_UP_BYTES, duration * 20_000_000) # ~20 MB/s cap on payload
57
+ start = time.monotonic()
58
+
59
+ resp = requests.post(
60
+ CF_UP_URL,
61
+ data=bytes(payload_size),
62
+ headers={"Content-Type": "application/octet-stream"},
63
+ timeout=_TIMEOUT,
64
+ )
65
+ resp.raise_for_status()
66
+
67
+ elapsed = time.monotonic() - start
68
+ return {
69
+ "bps": (payload_size * 8) / elapsed if elapsed > 0 else 0,
70
+ "bytes": payload_size,
71
+ "elapsed": round(elapsed, 2),
72
+ }
73
+
74
+
75
+ def run(duration: int = 5) -> dict:
76
+ """
77
+ Run download + upload speed test against Cloudflare.
78
+ Returns {"download": {...}, "upload": {...}, "server": "speed.cloudflare.com"}.
79
+ """
80
+ try:
81
+ download = _download(duration)
82
+ except Exception as e:
83
+ raise RuntimeError(f"Download test failed: {e}")
84
+
85
+ try:
86
+ upload = _upload(duration)
87
+ except Exception as e:
88
+ raise RuntimeError(f"Upload test failed: {e}")
89
+
90
+ return {
91
+ "download": download,
92
+ "upload": upload,
93
+ "server": "speed.cloudflare.com",
94
+ }
95
+
96
+
97
+ def extract_stats(result: dict) -> tuple[dict, dict]:
98
+ """Return (upload_stats, download_stats) in the format display expects."""
99
+ dl = result["download"]
100
+ ul = result["upload"]
101
+ upload_stats = {
102
+ "bps": ul["bps"],
103
+ "bytes": ul["bytes"],
104
+ "retransmits": None,
105
+ }
106
+ download_stats = {
107
+ "bps": dl["bps"],
108
+ "recv_bps": dl["bps"],
109
+ "bytes": dl["bytes"],
110
+ "ttfb_ms": dl.get("ttfb_ms"),
111
+ }
112
+ return upload_stats, download_stats