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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
netpath/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from netpath.cli import run
2
+
3
+ run()
netpath/asn.py ADDED
@@ -0,0 +1,108 @@
1
+ import socket
2
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3
+
4
+
5
+ def resolve_host(hostname: str) -> str | None:
6
+ try:
7
+ return socket.gethostbyname(hostname)
8
+ except (socket.gaierror, OSError):
9
+ return None
10
+
11
+
12
+ def resolve_hosts_parallel(hostnames: list[str], workers: int = 50) -> dict[str, str]:
13
+ """Returns {hostname: ip} for successful resolutions."""
14
+ results = {}
15
+ with ThreadPoolExecutor(max_workers=workers) as ex:
16
+ future_to_host = {ex.submit(resolve_host, h): h for h in hostnames}
17
+ for future in as_completed(future_to_host):
18
+ host = future_to_host[future]
19
+ ip = future.result()
20
+ if ip:
21
+ results[host] = ip
22
+ return results
23
+
24
+
25
+ def _cymru_query(ips: list[str], timeout: int = 30) -> str:
26
+ """Raw Cymru bulk whois query. Returns the full response text."""
27
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
28
+ s.settimeout(timeout)
29
+ s.connect(("whois.cymru.com", 43))
30
+ s.sendall(("begin\nverbose\n" + "\n".join(ips) + "\nend\n").encode())
31
+ chunks = []
32
+ while True:
33
+ try:
34
+ data = s.recv(8192)
35
+ if not data:
36
+ break
37
+ chunks.append(data)
38
+ except socket.timeout:
39
+ break
40
+ s.close()
41
+ return b"".join(chunks).decode(errors="replace")
42
+
43
+
44
+ def cymru_bulk_lookup(ips: list[str], timeout: int = 30) -> dict[str, str]:
45
+ """
46
+ Bulk ASN lookup via Team Cymru whois.
47
+ Returns {ip: "AS12345"} for all IPs with known ASNs.
48
+ """
49
+ if not ips:
50
+ return {}
51
+ try:
52
+ response = _cymru_query(ips, timeout)
53
+ except OSError:
54
+ return {}
55
+
56
+ result: dict[str, str] = {}
57
+ for line in response.splitlines():
58
+ line = line.strip()
59
+ if not line or line.startswith("Bulk") or "|" not in line:
60
+ continue
61
+ parts = [p.strip() for p in line.split("|")]
62
+ if len(parts) < 2:
63
+ continue
64
+ asn_num, ip = parts[0], parts[1]
65
+ if asn_num and asn_num not in ("NA", ""):
66
+ result[ip] = f"AS{asn_num}"
67
+ return result
68
+
69
+
70
+ def cymru_bulk_lookup_rich(ips: list[str], timeout: int = 30) -> dict[str, dict]:
71
+ """
72
+ Bulk Cymru whois returning full record per IP.
73
+ Response format: ASN | IP | prefix | country | registry | date | name
74
+ Returns {ip: {asn, prefix, name}}.
75
+ """
76
+ if not ips:
77
+ return {}
78
+ try:
79
+ response = _cymru_query(ips, timeout)
80
+ except OSError:
81
+ return {}
82
+
83
+ result: dict[str, dict] = {}
84
+ for line in response.splitlines():
85
+ line = line.strip()
86
+ if not line or line.startswith("Bulk") or "|" not in line:
87
+ continue
88
+ parts = [p.strip() for p in line.split("|")]
89
+ if len(parts) < 3:
90
+ continue
91
+ asn_num = parts[0]
92
+ ip = parts[1]
93
+ prefix = parts[2] if len(parts) > 2 else ""
94
+ name = parts[6].split(",")[0].strip() if len(parts) > 6 else ""
95
+ if asn_num and asn_num not in ("NA", "") and ip:
96
+ result[ip] = {
97
+ "asn": f"AS{asn_num}",
98
+ "prefix": prefix,
99
+ "name": name,
100
+ }
101
+ return result
102
+
103
+
104
+ def normalize_asn(asn: str) -> str:
105
+ asn = asn.upper().strip()
106
+ if not asn.startswith("AS"):
107
+ asn = f"AS{asn}"
108
+ return asn
netpath/cli.py ADDED
@@ -0,0 +1,526 @@
1
+ import json
2
+ import os
3
+ import queue
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ import threading
8
+ import typer
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn
10
+
11
+ from netpath import __version__
12
+ from netpath import country as country_mod
13
+ from netpath import display, globe as globe_mod, iperf as iperf_mod, mtr, rum as rum_mod, servers, speedtest
14
+ from netpath.asn import normalize_asn
15
+ from netpath.diagnosis import diagnose
16
+
17
+ app = typer.Typer(
18
+ help="netpath — probe throughput, latency, and packet loss to an ASN.",
19
+ add_completion=False,
20
+ no_args_is_help=True,
21
+ )
22
+
23
+ # ── shared options ────────────────────────────────────────────────────────────
24
+
25
+ _COUNT = typer.Option(3, "--count", "-n", help="Max servers / endpoints to test")
26
+ _DUR = typer.Option(5, "--duration", "-d", help="iperf3 seconds per direction")
27
+ _CYCLES = typer.Option(10, "--cycles", "-c", help="Probe cycles (mtr) / probes (traceroute)")
28
+ _NO_TPUT = typer.Option(False, "--no-throughput", help="Skip throughput test")
29
+ _CF_TOK = typer.Option(None, "--cf-token",
30
+ envvar="NETPATH_CF_TOKEN",
31
+ help="Cloudflare API token with radar:read (or set NETPATH_CF_TOKEN)")
32
+
33
+
34
+ # ── internal helpers ──────────────────────────────────────────────────────────
35
+
36
+ def _extract_as_path(hubs: list[dict]) -> list[str]:
37
+ asns: list[str] = []
38
+ for h in hubs:
39
+ asn = h.get("ASN", "")
40
+ if asn and asn != "AS???" and (not asns or asns[-1] != asn):
41
+ asns.append(asn)
42
+ return asns
43
+
44
+
45
+ def _classify_path(hubs: list[dict], target_asn: str) -> dict:
46
+ """
47
+ Determine whether the traceroute reached target_asn.
48
+ Returns {complete, rtt_ms, entry_transit_asn}.
49
+ complete is True only when target_asn appears in at least one hub's ASN field.
50
+ rtt_ms is the Avg RTT of the last hub inside target_asn (None when incomplete).
51
+ entry_transit_asn is the last non-AS??? ASN before target_asn (complete) or
52
+ the last non-AS??? ASN seen (incomplete); None if no resolvable ASNs exist.
53
+ """
54
+ target_norm = normalize_asn(target_asn)
55
+
56
+ complete = any(
57
+ normalize_asn(h.get("ASN", "")) == target_norm
58
+ for h in hubs
59
+ if h.get("ASN") and h.get("ASN") != "AS???"
60
+ )
61
+
62
+ if complete:
63
+ rtt_ms = None
64
+ for h in reversed(hubs):
65
+ if (h.get("ASN") and normalize_asn(h["ASN"]) == target_norm
66
+ and h.get("host") not in ("???", None, "")
67
+ and h.get("Avg", 0) > 0):
68
+ rtt_ms = h["Avg"]
69
+ break
70
+
71
+ entry_transit_asn = None
72
+ prev_asn = None
73
+ for h in hubs:
74
+ asn = h.get("ASN", "")
75
+ if not asn or asn == "AS???":
76
+ continue
77
+ asn_norm = normalize_asn(asn)
78
+ if asn_norm == target_norm:
79
+ entry_transit_asn = prev_asn
80
+ break
81
+ prev_asn = asn_norm
82
+ else:
83
+ rtt_ms = None
84
+ entry_transit_asn = None
85
+ for h in hubs:
86
+ asn = h.get("ASN", "")
87
+ if asn and asn != "AS???":
88
+ entry_transit_asn = normalize_asn(asn)
89
+
90
+ return {"complete": complete, "rtt_ms": rtt_ms, "entry_transit_asn": entry_transit_asn}
91
+
92
+
93
+ def _parse_ping_avg(output: str) -> float | None:
94
+ m = re.search(r'rtt min/avg/max/mdev = [\d.]+/([\d.]+)/', output)
95
+ if m:
96
+ return float(m.group(1))
97
+ m = re.search(r'round-trip min/avg/max/stddev = [\d.]+/([\d.]+)/', output)
98
+ if m:
99
+ return float(m.group(1))
100
+ return None
101
+
102
+
103
+ def _run_ping_probe(host: str, duration: int, result_q: queue.Queue) -> None:
104
+ count = min(duration, 5)
105
+ try:
106
+ proc = subprocess.run(
107
+ ["ping", "-c", str(count), "-i", "1", host],
108
+ capture_output=True, text=True, timeout=count + 10,
109
+ )
110
+ if proc.returncode != 0:
111
+ stderr_lower = proc.stderr.lower()
112
+ if "permission" in stderr_lower or "operation not permitted" in stderr_lower:
113
+ result_q.put(None)
114
+ return
115
+ result_q.put(_parse_ping_avg(proc.stdout))
116
+ except FileNotFoundError:
117
+ result_q.put(None)
118
+ except PermissionError:
119
+ result_q.put(None)
120
+ except subprocess.TimeoutExpired:
121
+ result_q.put(None)
122
+ except Exception:
123
+ result_q.put(None)
124
+
125
+
126
+ def _check_deps(no_throughput: bool) -> bool:
127
+ if not mtr.available():
128
+ display.error("mtr not found — install with: brew install mtr")
129
+ raise typer.Exit(1)
130
+ return no_throughput
131
+
132
+
133
+ def _trace(host: str, cycles: int) -> tuple[list[dict], str]:
134
+ try:
135
+ return mtr.run(host, cycles=cycles), "mtr"
136
+ except mtr.MtrPermissionError:
137
+ return mtr.run_traceroute(host, probes=cycles), "traceroute"
138
+
139
+
140
+ def _fetch_rum(asn: str, cf_token: str | None) -> dict | None:
141
+ if not cf_token:
142
+ return None
143
+ try:
144
+ return rum_mod.fetch_asn_quality(asn, cf_token)
145
+ except ValueError as e:
146
+ display.warn(f"Cloudflare RUM: {e}")
147
+ return None
148
+ except Exception:
149
+ return None
150
+
151
+
152
+ def _run_test(host: str, port: int, server_meta: dict, target_asn: str,
153
+ cycles: int, duration: int, skip_throughput: bool,
154
+ cf_token: str | None = None, show_server_heading: bool = True,
155
+ json_mode: bool = False) -> dict:
156
+ """Run trace + optional throughput test. Returns enriched result dict."""
157
+ result: dict = {"as_path": [], "last_rtt_ms": None, "rum": None,
158
+ "hubs": [], "bufferbloat_ms": None,
159
+ "download_mbps": None, "upload_mbps": None, "verdict": {},
160
+ "path_complete": False, "verified_rtt_ms": None,
161
+ "entry_transit_asn": None}
162
+
163
+ if show_server_heading and not json_mode:
164
+ display.server_heading(server_meta)
165
+
166
+ if not json_mode:
167
+ display.console.print(f" [dim]Tracing path ({cycles} probes)…[/dim]")
168
+ try:
169
+ if not json_mode:
170
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"),
171
+ console=display.console, transient=True) as p:
172
+ p.add_task(f"probing → {host}", total=None)
173
+ hubs, method = _trace(host, cycles)
174
+ else:
175
+ hubs, method = _trace(host, cycles)
176
+ except RuntimeError as e:
177
+ if not json_mode:
178
+ display.error(f"trace: {e}")
179
+ return result
180
+
181
+ if method == "traceroute" and not json_mode:
182
+ display.console.print(" [dim](mtr unavailable — using traceroute + Cymru ASN lookup)[/dim]\n")
183
+
184
+ if not json_mode:
185
+ display.path_table(hubs, target_asn)
186
+ display.as_path_summary(hubs)
187
+
188
+ result["as_path"] = _extract_as_path(hubs)
189
+ result["hubs"] = hubs
190
+
191
+ # last_rtt_ms: last responsive hop regardless of ASN (backward compat for asn subcommand)
192
+ for _h in reversed(hubs):
193
+ if _h.get("host") not in ("???", None, "") and _h.get("Avg", 0) > 0:
194
+ result["last_rtt_ms"] = _h["Avg"]
195
+ break
196
+
197
+ classification = _classify_path(hubs, target_asn)
198
+ result["path_complete"] = classification["complete"]
199
+ result["verified_rtt_ms"] = classification["rtt_ms"]
200
+ result["entry_transit_asn"] = classification["entry_transit_asn"]
201
+
202
+ rum_data = _fetch_rum(target_asn, cf_token)
203
+ result["rum"] = rum_data
204
+
205
+ if skip_throughput:
206
+ if rum_data and not json_mode:
207
+ display.rum_only_panel(rum_data, target_asn)
208
+ verdict = diagnose(result)
209
+ result["verdict"] = verdict
210
+ if not json_mode:
211
+ display.verdict_panel(verdict)
212
+ return result
213
+
214
+ # iperf3 measures the actual path from this host to the server in target_asn.
215
+ # Fall back to HTTP speedtest only if iperf3 is unavailable (that measures
216
+ # user → Cloudflare, not user → target ASN).
217
+ if iperf_mod.available():
218
+ if not json_mode:
219
+ display.console.print(
220
+ f" [dim]Measuring throughput via iperf3 to {host}:{port} ({duration}s each direction)…[/dim]"
221
+ )
222
+ idle_rtt = result["last_rtt_ms"]
223
+ ping_q: queue.Queue = queue.Queue()
224
+ ping_thread = threading.Thread(
225
+ target=_run_ping_probe, args=(host, duration, ping_q), daemon=True
226
+ )
227
+ ping_thread.start()
228
+ try:
229
+ if not json_mode:
230
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"),
231
+ console=display.console, transient=True) as p:
232
+ p.add_task(f"iperf3 → {host} ↑↓", total=None)
233
+ upload, download = iperf_mod.run_bidirectional(host, port, duration)
234
+ else:
235
+ upload, download = iperf_mod.run_bidirectional(host, port, duration)
236
+ ping_thread.join(timeout=duration + 10)
237
+ try:
238
+ loaded_rtt = ping_q.get_nowait()
239
+ except queue.Empty:
240
+ loaded_rtt = None
241
+ if idle_rtt is not None and loaded_rtt is not None:
242
+ result["bufferbloat_ms"] = round(loaded_rtt - idle_rtt, 1)
243
+ result["download_mbps"] = download.get("recv_bps", download.get("bps", 0)) / 1e6
244
+ result["upload_mbps"] = upload.get("bps", 0) / 1e6
245
+ verdict = diagnose(result)
246
+ result["verdict"] = verdict
247
+ if not json_mode:
248
+ display.throughput_and_rum(upload, download, rum=rum_data,
249
+ server=f"{host} (iperf3)")
250
+ display.bufferbloat_line(idle_rtt, loaded_rtt)
251
+ display.verdict_panel(verdict)
252
+ return result
253
+ except RuntimeError as e:
254
+ ping_thread.join(timeout=5)
255
+ if not json_mode:
256
+ display.warn(f"iperf3 to {host}:{port} failed: {e}")
257
+ if rum_data:
258
+ display.rum_only_panel(rum_data, target_asn)
259
+ verdict = diagnose(result)
260
+ result["verdict"] = verdict
261
+ if not json_mode:
262
+ display.verdict_panel(verdict)
263
+ return result
264
+
265
+ # iperf3 not installed — fall back to HTTP speedtest as a baseline.
266
+ # This measures user → Cloudflare, NOT user → target ASN.
267
+ if not json_mode:
268
+ display.console.print(
269
+ f" [dim]iperf3 not installed — showing Cloudflare baseline "
270
+ f"(install iperf3 for cross-ASN measurement)…[/dim]"
271
+ )
272
+ try:
273
+ if not json_mode:
274
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"),
275
+ console=display.console, transient=True) as p:
276
+ p.add_task("speed.cloudflare.com ↑↓", total=None)
277
+ st_result = speedtest.run(duration=duration)
278
+ else:
279
+ st_result = speedtest.run(duration=duration)
280
+
281
+ upload, download = speedtest.extract_stats(st_result)
282
+ result["download_mbps"] = download.get("recv_bps", download.get("bps", 0)) / 1e6
283
+ result["upload_mbps"] = upload.get("bps", 0) / 1e6
284
+ if not json_mode:
285
+ display.throughput_and_rum(upload, download, rum=rum_data,
286
+ server="speed.cloudflare.com (baseline — not cross-ASN)")
287
+
288
+ except RuntimeError as e:
289
+ if not json_mode:
290
+ display.warn(f"speedtest: {e}")
291
+ if rum_data:
292
+ display.rum_only_panel(rum_data, target_asn)
293
+
294
+ verdict = diagnose(result)
295
+ result["verdict"] = verdict
296
+ if not json_mode:
297
+ display.verdict_panel(verdict)
298
+ return result
299
+
300
+
301
+ # ── asn subcommand ────────────────────────────────────────────────────────────
302
+
303
+ @app.command()
304
+ def asn(
305
+ target: str = typer.Argument(..., help="Target ASN, e.g. AS15169 or 15169"),
306
+ count: int = _COUNT,
307
+ duration: int = _DUR,
308
+ cycles: int = _CYCLES,
309
+ no_throughput: bool = _NO_TPUT,
310
+ cf_token: str | None = _CF_TOK,
311
+ output_json: bool = typer.Option(False, "--json", help="Output results as JSON to stdout; suppresses terminal display"),
312
+ globe: bool = typer.Option(False, "--globe", "-g", help="Open interactive 3D globe visualization after probe"),
313
+ ):
314
+ """Test latency, packet loss, and throughput to servers in a specific ASN."""
315
+ asn_norm = normalize_asn(target)
316
+ if globe and output_json:
317
+ print("Warning: --globe is ignored when --json is set", file=sys.stderr)
318
+ globe = False
319
+ if not output_json:
320
+ display.header(__version__)
321
+ skip_throughput = _check_deps(no_throughput)
322
+
323
+ if not output_json:
324
+ display.console.print(f"[dim]Scanning iperf3 servers in [bold]{asn_norm}[/bold]…[/dim]\n")
325
+ if not output_json:
326
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
327
+ console=display.console, transient=True) as progress:
328
+ progress.add_task("Resolving hostnames + bulk ASN lookup via Cymru…", total=None)
329
+ found = servers.find_servers_in_asn(asn_norm, max_count=count)
330
+ else:
331
+ found = servers.find_servers_in_asn(asn_norm, max_count=count)
332
+
333
+ if not found:
334
+ if output_json:
335
+ print(json.dumps({"error": f"No public iperf3 servers found in {asn_norm}"}, indent=2))
336
+ else:
337
+ display.error(
338
+ f"No public iperf3 servers found in {asn_norm}.\n"
339
+ " Try: https://github.com/R0GGER/public-iperf3-servers"
340
+ )
341
+ raise typer.Exit(1)
342
+
343
+ if not output_json:
344
+ display.console.print(f"[green]✓[/green] Found [bold]{len(found)}[/bold] server(s) in {asn_norm}\n")
345
+
346
+ if output_json:
347
+ server = found[0]
348
+ result = _run_test(
349
+ host=server["HOST"], port=server["port"],
350
+ server_meta=server, target_asn=asn_norm,
351
+ cycles=cycles, duration=duration,
352
+ skip_throughput=skip_throughput, cf_token=cf_token,
353
+ json_mode=True,
354
+ )
355
+ upload_mbps = result.get("upload_mbps")
356
+ download_mbps = result.get("download_mbps")
357
+ output = {
358
+ "asn": asn_norm,
359
+ "target_host": server["HOST"],
360
+ "path": [
361
+ {
362
+ "hop": hub.get("count"),
363
+ "host": hub.get("host"),
364
+ "asn": hub.get("ASN"),
365
+ "loss_pct": hub.get("Loss%"),
366
+ "avg_ms": hub.get("Avg"),
367
+ "best_ms": hub.get("Best"),
368
+ "worst_ms": hub.get("Wrst"),
369
+ "p50_ms": hub.get("p50"),
370
+ "p95_ms": hub.get("p95"),
371
+ "p99_ms": hub.get("p99"),
372
+ }
373
+ for hub in result.get("hubs", [])
374
+ ],
375
+ "throughput": (
376
+ {"upload_mbps": upload_mbps, "download_mbps": download_mbps}
377
+ if upload_mbps is not None or download_mbps is not None
378
+ else None
379
+ ),
380
+ "bufferbloat_ms": result.get("bufferbloat_ms"),
381
+ "rum": result.get("rum"),
382
+ "verdict": result.get("verdict", {}),
383
+ }
384
+ print(json.dumps(output, indent=2))
385
+ else:
386
+ last_hubs: list[dict] = []
387
+ for server in found:
388
+ r = _run_test(
389
+ host=server["HOST"], port=server["port"],
390
+ server_meta=server, target_asn=asn_norm,
391
+ cycles=cycles, duration=duration,
392
+ skip_throughput=skip_throughput, cf_token=cf_token,
393
+ )
394
+ last_hubs = r["hubs"]
395
+ if globe and last_hubs:
396
+ globe_mod.render({asn_norm: last_hubs})
397
+
398
+
399
+ # ── country subcommand ────────────────────────────────────────────────────────
400
+
401
+ @app.command()
402
+ def country(
403
+ code: str = typer.Argument(..., help="ISO country code, e.g. US, GB, IL"),
404
+ top: int = typer.Option(10, "--top", "-t", help="Number of top ASNs to test"),
405
+ count: int = _COUNT,
406
+ duration: int = _DUR,
407
+ cycles: int = _CYCLES,
408
+ no_throughput: bool = _NO_TPUT,
409
+ cf_token: str | None = _CF_TOK,
410
+ globe: bool = typer.Option(False, "--globe", "-g", help="Open interactive 3D globe visualization after probes"),
411
+ ):
412
+ """Test the top N ASNs (by allocated IPv4 address space) for a country."""
413
+ code = code.upper()
414
+ display.header(__version__)
415
+ skip_throughput = _check_deps(no_throughput)
416
+
417
+ display.console.print(
418
+ f"[dim]Ranking top {top} ASNs for [bold]{code}[/bold] "
419
+ f"via RIPE allocation data + Cymru…[/dim]\n"
420
+ )
421
+ try:
422
+ top_asns = country_mod.get_top_asns(code, top_n=top)
423
+ except Exception as e:
424
+ display.error(f"ASN lookup failed: {e}")
425
+ raise typer.Exit(1)
426
+
427
+ display.console.print(f"[green]✓[/green] Top {len(top_asns)} ASNs for [bold]{code}[/bold]:\n")
428
+ for i, a in enumerate(top_asns, 1):
429
+ name = display.clean_asn_name(a["name"])
430
+ meta_parts = []
431
+ if a.get("addresses"):
432
+ meta_parts.append(f"{a['addresses']:,} IPs")
433
+ if a.get("prefix_count"):
434
+ n = a["prefix_count"]
435
+ meta_parts.append(f"{n} prefix{'es' if n != 1 else ''}")
436
+ meta = f" [dim]{' · '.join(meta_parts)}[/dim]" if meta_parts else ""
437
+ display.console.print(f" {i:>2}. [bold yellow]{a['asn']}[/bold yellow] {name}{meta}")
438
+ display.console.print()
439
+
440
+ # Run HTTP speedtest ONCE as the user's own baseline before per-ISP tests
441
+ if not skip_throughput:
442
+ display.console.print("[dim]Measuring your connection baseline (speed.cloudflare.com)…[/dim]")
443
+ try:
444
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"),
445
+ console=display.console, transient=True) as p:
446
+ p.add_task("speed.cloudflare.com ↑↓", total=None)
447
+ st_result = speedtest.run(duration=duration)
448
+ ul, dl = speedtest.extract_stats(st_result)
449
+ display.baseline_panel(ul, dl)
450
+ except RuntimeError as e:
451
+ display.warn(f"Baseline speedtest failed: {e}")
452
+
453
+ # Warm the server cache once — all subsequent find_servers_in_asn calls are free
454
+ display.console.print("[dim]Fetching + resolving iperf3 server list…[/dim]")
455
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
456
+ console=display.console, transient=True) as progress:
457
+ progress.add_task("DNS + bulk ASN lookup via Cymru…", total=None)
458
+ servers._fetch_and_resolve()
459
+ display.console.print()
460
+
461
+ summary_rows: list[dict] = []
462
+ hubs_for_globe: dict[str, list[dict]] = {}
463
+
464
+ for i, asn_info in enumerate(top_asns, 1):
465
+ asn_str = asn_info["asn"]
466
+ isp_name = asn_info["name"]
467
+
468
+ display.isp_section(i, asn_str, isp_name,
469
+ asn_info.get("addresses", 0),
470
+ asn_info.get("prefix_count", 0))
471
+
472
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"),
473
+ console=display.console, transient=True) as p:
474
+ p.add_task(f"Searching iperf3 servers in {asn_str}…", total=None)
475
+ asn_servers = servers.find_servers_in_asn(asn_str, max_count=count)
476
+
477
+ if asn_servers:
478
+ server = asn_servers[0]
479
+ can_test_throughput = not no_throughput and iperf_mod.available()
480
+ r = _run_test(
481
+ host=server["HOST"], port=server["port"],
482
+ server_meta=server, target_asn=asn_str,
483
+ cycles=cycles, duration=duration,
484
+ skip_throughput=not can_test_throughput,
485
+ show_server_heading=True,
486
+ cf_token=cf_token,
487
+ )
488
+ else:
489
+ test_ip = country_mod.get_test_ip_for_asn(asn_str)
490
+ if not test_ip:
491
+ display.warn(f"Could not find a test IP for {asn_str} — skipping")
492
+ summary_rows.append({"asn": asn_str, "name": display.clean_asn_name(isp_name),
493
+ "as_path": [], "last_rtt_ms": None, "rum": None,
494
+ "path_complete": False, "verified_rtt_ms": None,
495
+ "entry_transit_asn": None})
496
+ continue
497
+
498
+ display.console.print(
499
+ f" [dim]→ {test_ip} (traceroute target — no iperf3 server in {asn_str})[/dim]\n"
500
+ )
501
+ meta = {"HOST": test_ip, "SITE": isp_name, "COUNTRY": code,
502
+ "asn": asn_str, "port": 5201}
503
+ r = _run_test(
504
+ host=test_ip, port=5201,
505
+ server_meta=meta, target_asn=asn_str,
506
+ cycles=cycles, duration=duration,
507
+ skip_throughput=True,
508
+ show_server_heading=False,
509
+ cf_token=cf_token,
510
+ )
511
+
512
+ summary_rows.append({
513
+ "asn": asn_str,
514
+ "name": display.clean_asn_name(isp_name),
515
+ **r,
516
+ })
517
+ if globe:
518
+ hubs_for_globe[asn_str] = r.get("hubs", [])
519
+
520
+ display.country_summary(code, summary_rows)
521
+ if globe and hubs_for_globe:
522
+ globe_mod.render(hubs_for_globe)
523
+
524
+
525
+ def run():
526
+ app()