ifpretty 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.
ifpretty/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """ifpretty — prettify and enrich `ifconfig` output."""
2
+
3
+ from .cli import main
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["main", "__version__"]
ifpretty/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable `python -m ifpretty`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
ifpretty/cli.py ADDED
@@ -0,0 +1,1215 @@
1
+ """ifpretty — render `ifconfig` output as aligned tables (using rich).
2
+
3
+ Usage:
4
+ ifconfig | ifpretty # boxed table per interface (default), borders aligned
5
+ ifconfig | ifpretty --records # compact label/value record layout
6
+ ifconfig | ifpretty --table # single wide table, all fields as columns (<=240)
7
+ ifconfig | ifpretty --live # +route, traffic, friendly names, DNS, DHCP (macOS)
8
+ ifconfig | ifpretty --check # +ping the default gateway for latency (macOS)
9
+ ifconfig | ifpretty --explain # append a legend explaining the flags/options seen
10
+ ifconfig en0 | ifpretty
11
+
12
+ Architecture (thin layers, one file):
13
+ MODEL dataclasses Inet / Inet6 / Interface
14
+ PARSE parse(text) -> list[Interface]
15
+ ENRICH Tier 1 (pure derivation: role, IP scope, MAC kind+vendor, net info,
16
+ EUI-64, shared MACs, MTU/media notes) and Tier 2 (--live / --check:
17
+ route/netstat/networksetup/scutil/ipconfig/ping cross-reference)
18
+ FORMAT value formatters — the single source of truth for how a value looks;
19
+ interface_fields() gives the canonical (label, value) ordering
20
+ RENDER render_boxed / render_records / render_table (selected via RENDERERS)
21
+
22
+ Every field ifpretty understands is preserved; unmodeled lines (e.g. a bridge's
23
+ config block) are kept verbatim and shown under the "bridge" label.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import gzip
30
+ import importlib.resources
31
+ import ipaddress
32
+ import os
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import time
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+ from typing import Callable
41
+
42
+ try:
43
+ from rich import box
44
+ from rich.console import Console
45
+ from rich.table import Table
46
+ from rich.text import Text
47
+ except ImportError:
48
+ sys.exit(
49
+ "ifpretty: the 'rich' library is required.\n"
50
+ " install it with: python3 -m pip install rich"
51
+ )
52
+
53
+
54
+ # ── MODEL ─────────────────────────────────────────────────────────────────────
55
+ @dataclass(frozen=True)
56
+ class Inet:
57
+ addr: str
58
+ netmask: str = "" # already CIDR form, e.g. "255.255.255.0/24"
59
+ broadcast: str = ""
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Inet6:
64
+ addr: str
65
+ prefixlen: str = ""
66
+ attrs: tuple[str, ...] = ()
67
+
68
+
69
+ @dataclass
70
+ class Interface:
71
+ name: str
72
+ mtu: str = ""
73
+ flags: list[str] = field(default_factory=list)
74
+ options: list[str] = field(default_factory=list)
75
+ ether: str = ""
76
+ inet: list[Inet] = field(default_factory=list)
77
+ inet6: list[Inet6] = field(default_factory=list)
78
+ nd6: list[str] = field(default_factory=list)
79
+ media: str = ""
80
+ status: str = ""
81
+ other: list[str] = field(default_factory=list) # unmodeled lines, verbatim
82
+
83
+ # Tier-2 live enrichment (populated only with --live; -1 / "" mean "unknown")
84
+ hw_port: str = "" # friendly name from networksetup, e.g. "Wi-Fi"
85
+ gateway: str = "" # set on the default-route interface
86
+ is_default: bool = False
87
+ rx_bytes: int = -1
88
+ tx_bytes: int = -1
89
+ rx_pkts: int = -1
90
+ tx_pkts: int = -1
91
+ rx_errs: int = 0
92
+ tx_errs: int = 0
93
+ collisions: int = 0
94
+ dns: list[str] = field(default_factory=list)
95
+ search_domains: list[str] = field(default_factory=list)
96
+ dhcp_server: str = ""
97
+ dhcp_lease: int = -1 # seconds
98
+ gw_latency_ms: float = -1.0 # set by --check
99
+ gw_loss: bool = False
100
+ shares_mac_with: list[str] = field(default_factory=list) # Tier-1 derivation
101
+ rx_hist: list[float] = field(default_factory=list) # bytes/s, from --record history
102
+ tx_hist: list[float] = field(default_factory=list)
103
+
104
+ @property
105
+ def is_up(self) -> bool:
106
+ return "UP" in self.flags
107
+
108
+ @property
109
+ def role(self) -> str:
110
+ return interface_role(self.name)
111
+
112
+
113
+ # ── PARSE ─────────────────────────────────────────────────────────────────────
114
+ def hexmask_to_cidr(mask: str) -> str:
115
+ """Convert an ifconfig netmask (0xffffff00) to '255.255.255.0/24'."""
116
+ try:
117
+ value = int(mask, 16)
118
+ except ValueError:
119
+ return mask
120
+ octets = [(value >> shift) & 0xFF for shift in (24, 16, 8, 0)]
121
+ bits = bin(value).count("1")
122
+ return f"{'.'.join(map(str, octets))}/{bits}"
123
+
124
+
125
+ # words that are values even though they're alphabetic (no digit/colon to give them away)
126
+ _VALUE_WORDS = {
127
+ "stp",
128
+ "rstp",
129
+ "disabled",
130
+ "enabled",
131
+ "up",
132
+ "down",
133
+ "active",
134
+ "inactive",
135
+ "none",
136
+ "auto",
137
+ }
138
+
139
+
140
+ def _is_value(tok: str) -> bool:
141
+ if "=" in tok or tok.endswith(":"):
142
+ return False
143
+ if any(ch.isdigit() for ch in tok):
144
+ return True
145
+ if ":" in tok: # mac / ipv6 fragment
146
+ return True
147
+ return tok.lower() in _VALUE_WORDS
148
+
149
+
150
+ def kv_pairs(s: str) -> list[tuple[str, str]]:
151
+ """Split a multi-field line into (key, value) pairs.
152
+
153
+ Handles `key value` runs (multi-word keys allowed), `label:` headers, and
154
+ `name=hex<FLAG,FLAG>` blobs. Returns ('', value) for a bare value and
155
+ (label, '') for a header so the renderer can style each kind.
156
+ """
157
+ pairs: list[tuple[str, str]] = []
158
+ key_parts: list[str] = []
159
+
160
+ def flush_label() -> None:
161
+ if key_parts:
162
+ pairs.append((" ".join(key_parts), ""))
163
+ key_parts.clear()
164
+
165
+ for tok in s.split():
166
+ if "=" in tok: # name=hex<FLAGS> or name=value
167
+ flush_label()
168
+ name, _, val = tok.partition("=")
169
+ fm = re.search(r"<([^>]*)>", val)
170
+ if fm:
171
+ val = " ".join(f for f in fm.group(1).split(",") if f)
172
+ pairs.append((name, val))
173
+ elif tok.endswith(":"): # completes a label phrase ("Address cache:")
174
+ key_parts.append(tok[:-1])
175
+ elif _is_value(tok):
176
+ pairs.append((" ".join(key_parts), tok))
177
+ key_parts.clear()
178
+ else:
179
+ key_parts.append(tok)
180
+ flush_label()
181
+ return pairs
182
+
183
+
184
+ def _parse_inet(parts: list[str]) -> Inet:
185
+ addr = parts[1] if len(parts) > 1 else ""
186
+ netmask = broadcast = ""
187
+ i = 2
188
+ while i < len(parts) - 1:
189
+ if parts[i] == "netmask":
190
+ netmask = hexmask_to_cidr(parts[i + 1])
191
+ elif parts[i] == "broadcast":
192
+ broadcast = parts[i + 1]
193
+ i += 2
194
+ return Inet(addr, netmask, broadcast)
195
+
196
+
197
+ def _parse_inet6(parts: list[str]) -> Inet6:
198
+ addr = parts[1] if len(parts) > 1 else ""
199
+ prefixlen = ""
200
+ attrs: list[str] = []
201
+ i = 2
202
+ while i < len(parts):
203
+ if parts[i] in ("prefixlen", "scopeid") and i + 1 < len(parts):
204
+ if parts[i] == "prefixlen":
205
+ prefixlen = parts[i + 1]
206
+ i += 2
207
+ else:
208
+ attrs.append(parts[i])
209
+ i += 1
210
+ return Inet6(addr, prefixlen, tuple(attrs))
211
+
212
+
213
+ def _flags_in(rest: str) -> list[str]:
214
+ m = re.search(r"flags=\w+<([^>]*)>", rest)
215
+ return [f for f in m.group(1).split(",") if f] if m else []
216
+
217
+
218
+ def parse(text: str) -> list[Interface]:
219
+ """Parse ifconfig text into a list of Interface records."""
220
+ interfaces: list[Interface] = []
221
+ cur: Interface | None = None
222
+
223
+ for raw in text.splitlines():
224
+ if not raw.strip():
225
+ continue
226
+
227
+ if not raw[0].isspace(): # interface header at column 0
228
+ m = re.match(r"^(\S+?):\s+(.*)$", raw)
229
+ name, rest = m.groups() if m else (raw.rstrip(":"), "")
230
+ mtu = re.search(r"mtu (\d+)", rest)
231
+ cur = Interface(
232
+ name=name, flags=_flags_in(rest), mtu=mtu.group(1) if mtu else ""
233
+ )
234
+ interfaces.append(cur)
235
+ continue
236
+
237
+ if cur is None: # detail before any header — shouldn't happen
238
+ continue
239
+
240
+ s = raw.strip()
241
+ if s.startswith("inet "):
242
+ cur.inet.append(_parse_inet(s.split()))
243
+ elif s.startswith("inet6 "):
244
+ cur.inet6.append(_parse_inet6(s.split()))
245
+ elif s.startswith("ether "):
246
+ cur.ether = s.split(None, 1)[1]
247
+ elif s.startswith("status:"):
248
+ cur.status = s.split(":", 1)[1].strip()
249
+ elif s.startswith("media:"):
250
+ cur.media = s.split(":", 1)[1].strip()
251
+ elif m := re.match(r"^options=\w+<([^>]*)>$", s):
252
+ cur.options = [f for f in m.group(1).split(",") if f]
253
+ elif m := re.match(r"^nd6 options=\w+<([^>]*)>$", s):
254
+ cur.nd6 = [f for f in m.group(1).split(",") if f]
255
+ else:
256
+ cur.other.append(s)
257
+
258
+ return interfaces
259
+
260
+
261
+ # ── ENRICH (Tier 1: pure derivation, offline, deterministic) ──────────────────
262
+ _ROLES = {
263
+ "lo": "loopback",
264
+ "gif": "tunnel",
265
+ "stf": "6to4 tunnel",
266
+ "awdl": "Apple Wireless Direct",
267
+ "llw": "low-latency WLAN",
268
+ "utun": "VPN/tunnel",
269
+ "bridge": "bridge",
270
+ "ap": "access point",
271
+ "en": "Ethernet/Wi-Fi",
272
+ "anpi": "Apple private",
273
+ }
274
+
275
+ _ULA = ipaddress.ip_network("fc00::/7")
276
+
277
+
278
+ def interface_role(name: str) -> str:
279
+ m = re.match(r"^([a-z]+)", name)
280
+ return _ROLES.get(m.group(1), "") if m else ""
281
+
282
+
283
+ def mac_kind(ether: str) -> str:
284
+ """'universal' (burned-in) vs 'locally administered' (randomized/virtual)."""
285
+ if not ether:
286
+ return ""
287
+ try:
288
+ first = int(ether.split(":")[0], 16)
289
+ except ValueError:
290
+ return ""
291
+ if first & 0x01:
292
+ return "multicast"
293
+ return "locally administered" if first & 0x02 else "universal"
294
+
295
+
296
+ # OUI vendor lookup, lazily loaded from the bundled IEEE registry (offline).
297
+ _OUI: dict[str, str] | None = None
298
+
299
+
300
+ def _load_oui() -> dict[str, str]:
301
+ global _OUI
302
+ if _OUI is None:
303
+ _OUI = {}
304
+ try:
305
+ res = importlib.resources.files("ifpretty").joinpath("data/oui.tsv.gz")
306
+ text = gzip.decompress(res.read_bytes()).decode("utf-8")
307
+ for line in text.splitlines():
308
+ prefix, _, org = line.partition("\t")
309
+ if org:
310
+ _OUI[prefix] = org
311
+ except (OSError, FileNotFoundError, ModuleNotFoundError):
312
+ pass # no bundled DB → vendor lookup silently disabled
313
+ return _OUI
314
+
315
+
316
+ def mac_vendor(ether: str) -> str:
317
+ """IEEE-registered vendor for a universal MAC; '' for local/multicast/unknown."""
318
+ if mac_kind(ether) != "universal":
319
+ return ""
320
+ key = ether.replace(":", "").upper()[:6]
321
+ return _load_oui().get(key, "")
322
+
323
+
324
+ def is_eui64(addr: str) -> bool:
325
+ """True if an IPv6 interface ID is derived from a MAC (modified EUI-64: ff:fe)."""
326
+ try:
327
+ ip = ipaddress.ip_address(addr.split("%")[0])
328
+ except ValueError:
329
+ return False
330
+ if ip.version != 6:
331
+ return False
332
+ iid = int(ip) & ((1 << 64) - 1) # low 64 bits
333
+ return (iid >> 24) & 0xFFFF == 0xFFFE # bytes 3-4 of the interface identifier
334
+
335
+
336
+ def mtu_note(mtu: str) -> str:
337
+ if not mtu.isdigit():
338
+ return ""
339
+ n = int(mtu)
340
+ if n >= 9000:
341
+ return " [dim]· jumbo frames[/]"
342
+ if n < 1280:
343
+ return " [yellow]· below IPv6 minimum[/]"
344
+ return ""
345
+
346
+
347
+ def derive_shared_macs(interfaces: list[Interface]) -> None:
348
+ """Tier 1: note interfaces that share an Ethernet address (e.g. bridge members)."""
349
+ seen: dict[str, list[str]] = {}
350
+ for i in interfaces:
351
+ if i.ether:
352
+ seen.setdefault(i.ether, []).append(i.name)
353
+ for i in interfaces:
354
+ peers = [n for n in seen.get(i.ether, []) if n != i.name]
355
+ i.shares_mac_with = peers
356
+
357
+
358
+ def ip_scope(addr: str) -> str:
359
+ """Classify an address: loopback / link-local / ULA / private / global."""
360
+ try:
361
+ ip = ipaddress.ip_address(addr.split("%")[0])
362
+ except ValueError:
363
+ return ""
364
+ if ip.is_loopback:
365
+ return "loopback"
366
+ if ip.is_link_local:
367
+ return "link-local"
368
+ if ip.version == 6 and ip in _ULA:
369
+ return "ULA"
370
+ if ip.is_private:
371
+ return "private"
372
+ if ip.is_global:
373
+ return "global"
374
+ return "reserved"
375
+
376
+
377
+ def ipv4_net_info(x: Inet) -> str:
378
+ """'net 192.168.1.0/24 · 254 hosts' from an IPv4 address + netmask."""
379
+ if "/" not in x.netmask:
380
+ return ""
381
+ try:
382
+ net = ipaddress.ip_network(f"{x.addr}/{x.netmask.split('/')[-1]}", strict=False)
383
+ except ValueError:
384
+ return ""
385
+ usable = net.num_addresses - 2 if net.prefixlen <= 30 else net.num_addresses
386
+ return f"net {net.with_prefixlen} · {usable} hosts"
387
+
388
+
389
+ def humanize_bytes(n: int) -> str:
390
+ if n < 0:
391
+ return ""
392
+ size = float(n)
393
+ for unit in ("B", "KB", "MB", "GB", "TB"):
394
+ if size < 1024 or unit == "TB":
395
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
396
+ size /= 1024
397
+ return f"{size:.1f} PB"
398
+
399
+
400
+ def humanize_count(n: int) -> str:
401
+ if n < 0:
402
+ return ""
403
+ size = float(n)
404
+ for unit in ("", "k", "M", "G"):
405
+ if size < 1000 or unit == "G":
406
+ return f"{size:.0f}{unit}" if unit == "" else f"{size:.1f}{unit}"
407
+ size /= 1000
408
+ return f"{size:.1f}G"
409
+
410
+
411
+ def humanize_secs(s: int) -> str:
412
+ if s < 0:
413
+ return ""
414
+ for limit, unit in ((86400, "d"), (3600, "h"), (60, "m")):
415
+ if s >= limit:
416
+ return f"{s // limit}{unit}"
417
+ return f"{s}s"
418
+
419
+
420
+ def humanize_rate(bytes_per_s: float) -> str:
421
+ bits = bytes_per_s * 8
422
+ for unit in ("bps", "Kbps", "Mbps", "Gbps"):
423
+ if bits < 1000 or unit == "Gbps":
424
+ return f"{bits:.0f} {unit}" if unit == "bps" else f"{bits:.1f} {unit}"
425
+ bits /= 1000
426
+ return f"{bits:.1f} Gbps"
427
+
428
+
429
+ _SPARK = "▁▂▃▄▅▆▇█"
430
+
431
+
432
+ def sparkline(values: list[float]) -> str:
433
+ """Unicode block sparkline scaled 0..peak (single-width chars, alignment-safe)."""
434
+ if not values:
435
+ return ""
436
+ peak = max(values) or 1.0
437
+ return "".join(_SPARK[min(7, int(v / peak * 7.999))] for v in values)
438
+
439
+
440
+ def bar(frac: float, width: int = 10) -> str:
441
+ """Horizontal gauge from full/light blocks (single-width, alignment-safe)."""
442
+ n = round(max(0.0, min(1.0, frac)) * width)
443
+ return "█" * n + "░" * (width - n)
444
+
445
+
446
+ def link_mbps(media: str) -> int:
447
+ m = re.search(r"(\d+)(G?)[Bb]ase", media or "")
448
+ if not m:
449
+ return 0
450
+ return int(m.group(1)) * 1000 if m.group(2) == "G" else int(m.group(1))
451
+
452
+
453
+ def media_note(media: str) -> str:
454
+ """Derive 'speed full-duplex' from a media string; warn on half-duplex."""
455
+ if not media:
456
+ return ""
457
+ parts = []
458
+ m = re.search(r"(\d+)(G?)[Bb]ase", media)
459
+ if m:
460
+ n = int(m.group(1))
461
+ if m.group(2) == "G":
462
+ parts.append(f"{n} Gbps")
463
+ elif n >= 1000 and n % 1000 == 0:
464
+ parts.append(f"{n // 1000} Gbps")
465
+ else:
466
+ parts.append(f"{n} Mbps")
467
+ if "full-duplex" in media:
468
+ parts.append("full-duplex")
469
+ out = f" [dim]· {' '.join(parts)}[/]" if parts else ""
470
+ if "half-duplex" in media:
471
+ out += " [yellow]⚠ half-duplex[/]"
472
+ return out
473
+
474
+
475
+ def traffic_value(iface: Interface) -> str:
476
+ if iface.rx_bytes < 0:
477
+ return ""
478
+ rp = f" ({humanize_count(iface.rx_pkts)} pkts)" if iface.rx_pkts >= 0 else ""
479
+ tp = f" ({humanize_count(iface.tx_pkts)} pkts)" if iface.tx_pkts >= 0 else ""
480
+ return (
481
+ f"↓ {humanize_bytes(iface.rx_bytes)}{rp}"
482
+ f" ↑ {humanize_bytes(iface.tx_bytes)}{tp}"
483
+ )
484
+
485
+
486
+ def errors_value(iface: Interface) -> str:
487
+ """Non-empty only when traffic was collected (--live) and something is nonzero."""
488
+ if iface.rx_bytes < 0 or (iface.rx_errs == iface.tx_errs == iface.collisions == 0):
489
+ return ""
490
+ return f"in {iface.rx_errs} out {iface.tx_errs} coll {iface.collisions}"
491
+
492
+
493
+ # ── ENRICH (Tier 2: local system cross-reference, opt-in via --live) ──────────
494
+ def _run(cmd: list[str], timeout: float = 3.0) -> str | None:
495
+ """Run a helper command; return stdout, or None (with a stderr note) on failure."""
496
+ try:
497
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
498
+ except (OSError, subprocess.SubprocessError) as e:
499
+ print(f"ifpretty: --live: {cmd[0]} failed ({e})", file=sys.stderr)
500
+ return None
501
+ return r.stdout or None # route(8) returns nonzero with no default route — tolerate
502
+
503
+
504
+ def _apply_default_route(by_name: dict[str, Interface]) -> None:
505
+ out = _run(["route", "-n", "get", "default"])
506
+ if not out:
507
+ return
508
+ gateway = iface = ""
509
+ for line in out.splitlines():
510
+ line = line.strip()
511
+ if line.startswith("gateway:"):
512
+ gateway = line.split(":", 1)[1].strip()
513
+ elif line.startswith("interface:"):
514
+ iface = line.split(":", 1)[1].strip()
515
+ if iface in by_name:
516
+ by_name[iface].is_default = True
517
+ by_name[iface].gateway = gateway
518
+
519
+
520
+ def _read_link_counters() -> dict[str, tuple[int, ...]]:
521
+ """{ifname: (rx_pkts, rx_errs, rx_bytes, tx_pkts, tx_errs, tx_bytes, coll)}."""
522
+ out: dict[str, tuple[int, ...]] = {}
523
+ text = _run(["netstat", "-ib"])
524
+ if not text:
525
+ return out
526
+ for line in text.splitlines()[1:]:
527
+ f = line.split()
528
+ if len(f) < 6 or not f[2].startswith("<Link"):
529
+ continue
530
+ nums = f[3:]
531
+ if nums and ":" in nums[0]: # drop the MAC column when present
532
+ nums = nums[1:]
533
+ try: # nums = [Ipkts, Ierrs, Ibytes, Opkts, Oerrs, Obytes, Coll]
534
+ vals = tuple(int(nums[i]) for i in range(6))
535
+ except (ValueError, IndexError):
536
+ continue
537
+ coll = int(nums[6]) if len(nums) > 6 and nums[6].isdigit() else 0
538
+ out[f[0]] = vals + (coll,)
539
+ return out
540
+
541
+
542
+ def _apply_traffic(by_name: dict[str, Interface]) -> None:
543
+ for name, v in _read_link_counters().items():
544
+ if name in by_name:
545
+ i = by_name[name]
546
+ (
547
+ i.rx_pkts,
548
+ i.rx_errs,
549
+ i.rx_bytes,
550
+ i.tx_pkts,
551
+ i.tx_errs,
552
+ i.tx_bytes,
553
+ i.collisions,
554
+ ) = v
555
+
556
+
557
+ def _apply_hw_ports(by_name: dict[str, Interface]) -> None:
558
+ out = _run(["networksetup", "-listallhardwareports"])
559
+ if not out:
560
+ return
561
+ port = ""
562
+ for line in out.splitlines():
563
+ line = line.strip()
564
+ if line.startswith("Hardware Port:"):
565
+ port = line.split(":", 1)[1].strip()
566
+ elif line.startswith("Device:"):
567
+ dev = line.split(":", 1)[1].strip()
568
+ if dev in by_name:
569
+ by_name[dev].hw_port = port
570
+
571
+
572
+ def _apply_dns(by_name: dict[str, Interface]) -> None:
573
+ out = _run(["scutil", "--dns"])
574
+ if not out:
575
+ return
576
+ for block in re.split(r"resolver #\d+", out):
577
+ m = re.search(r"if_index\s*:\s*\d+\s*\(([^)]+)\)", block)
578
+ if not m or m.group(1) not in by_name:
579
+ continue
580
+ iface = by_name[m.group(1)]
581
+ for ns in re.findall(r"nameserver\[\d+\]\s*:\s*(\S+)", block):
582
+ if ns not in iface.dns:
583
+ iface.dns.append(ns)
584
+ for sd in re.findall(r"search domain\[\d+\]\s*:\s*(\S+)", block):
585
+ if sd not in iface.search_domains:
586
+ iface.search_domains.append(sd)
587
+
588
+
589
+ def _apply_dhcp(by_name: dict[str, Interface]) -> None:
590
+ for name, iface in by_name.items():
591
+ if not iface.inet: # only DHCP-capable (IPv4-configured) interfaces
592
+ continue
593
+ server = _run(["ipconfig", "getoption", name, "server_identifier"])
594
+ if server and server.strip():
595
+ iface.dhcp_server = server.strip()
596
+ lease = _run(["ipconfig", "getoption", name, "lease_time"])
597
+ if lease and lease.strip().isdigit():
598
+ iface.dhcp_lease = int(lease.strip())
599
+
600
+
601
+ def enrich_live(interfaces: list[Interface]) -> None:
602
+ """Augment interfaces with route, traffic, names, DNS and DHCP lease."""
603
+ if sys.platform != "darwin":
604
+ print("ifpretty: --live enrichment is macOS-only; skipping", file=sys.stderr)
605
+ return
606
+ by_name = {i.name: i for i in interfaces}
607
+ _apply_default_route(by_name)
608
+ _apply_traffic(by_name)
609
+ _apply_hw_ports(by_name)
610
+ _apply_dns(by_name)
611
+ _apply_dhcp(by_name)
612
+
613
+
614
+ def _apply_gateway_ping(interfaces: list[Interface]) -> None:
615
+ for iface in interfaces:
616
+ if not (iface.is_default and iface.gateway):
617
+ continue
618
+ out = _run(["ping", "-c", "2", "-t", "2", iface.gateway], timeout=5.0)
619
+ m = re.search(r"=\s*[\d.]+/([\d.]+)/", out) if out else None
620
+ if m:
621
+ iface.gw_latency_ms = float(m.group(1))
622
+ else:
623
+ iface.gw_loss = True
624
+
625
+
626
+ def enrich_check(interfaces: list[Interface]) -> None:
627
+ """Active health probe (--check): ping the default gateway."""
628
+ if sys.platform != "darwin":
629
+ print("ifpretty: --check is macOS-only; skipping", file=sys.stderr)
630
+ return
631
+ by_name = {i.name: i for i in interfaces}
632
+ if not any(i.is_default for i in interfaces):
633
+ _apply_default_route(by_name) # need the gateway if --live wasn't used
634
+ _apply_gateway_ping(interfaces)
635
+
636
+
637
+ # ── ENRICH (bandwidth history: --record samples counters; rendered as a 1h graph)
638
+ _HISTORY_WINDOW = 3600 # seconds of history kept / graphed
639
+ _HISTORY_PATH = Path(
640
+ os.environ.get("IFPRETTY_HISTORY")
641
+ or Path.home() / ".cache" / "ifpretty" / "samples.csv"
642
+ )
643
+
644
+
645
+ def _load_samples() -> list[tuple[int, str, int, int]]:
646
+ """Each row: (epoch, ifname, rx_bytes, tx_bytes)."""
647
+ rows: list[tuple[int, str, int, int]] = []
648
+ try:
649
+ text = _HISTORY_PATH.read_text()
650
+ except OSError:
651
+ return rows
652
+ for line in text.splitlines():
653
+ parts = line.split(",")
654
+ if len(parts) != 4:
655
+ continue
656
+ try:
657
+ rows.append((int(parts[0]), parts[1], int(parts[2]), int(parts[3])))
658
+ except ValueError:
659
+ continue
660
+ return rows
661
+
662
+
663
+ def record_sample() -> int:
664
+ """Append current per-interface byte counters to the history file (for --record)."""
665
+ if sys.platform != "darwin":
666
+ print("ifpretty: --record is macOS-only", file=sys.stderr)
667
+ return 1
668
+ counters = _read_link_counters()
669
+ if not counters:
670
+ print("ifpretty: --record: could not read counters", file=sys.stderr)
671
+ return 1
672
+ now = int(time.time())
673
+ cutoff = now - 2 * _HISTORY_WINDOW
674
+ rows = [r for r in _load_samples() if r[0] >= cutoff]
675
+ rows += [(now, name, v[2], v[5]) for name, v in counters.items()]
676
+ _HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
677
+ _HISTORY_PATH.write_text("".join(f"{t},{n},{rx},{tx}\n" for t, n, rx, tx in rows))
678
+ return 0
679
+
680
+
681
+ def _rates(points: list[tuple[int, int, int]]) -> tuple[list[float], list[float]]:
682
+ """Consecutive (ts, rx, tx) samples -> (rx_bytes/s, tx_bytes/s), resets clamped."""
683
+ rx_rates, tx_rates = [], []
684
+ for (t0, rx0, tx0), (t1, rx1, tx1) in zip(points, points[1:]):
685
+ dt = t1 - t0
686
+ if dt <= 0:
687
+ continue
688
+ rx_rates.append(max(0, rx1 - rx0) / dt)
689
+ tx_rates.append(max(0, tx1 - tx0) / dt)
690
+ return rx_rates, tx_rates
691
+
692
+
693
+ def enrich_history(interfaces: list[Interface]) -> None:
694
+ """Attach the last hour of throughput rates from the --record datastore."""
695
+ samples = _load_samples()
696
+ if not samples:
697
+ return
698
+ now = max(ts for ts, _, _, _ in samples)
699
+ by_iface: dict[str, list[tuple[int, int, int]]] = {}
700
+ for ts, name, rx, tx in samples:
701
+ if now - ts <= _HISTORY_WINDOW:
702
+ by_iface.setdefault(name, []).append((ts, rx, tx))
703
+ for iface in interfaces:
704
+ pts = sorted(by_iface.get(iface.name, []))[-61:] # ~1h at 1/min
705
+ iface.rx_hist, iface.tx_hist = _rates(pts)
706
+
707
+
708
+ # ── FORMAT — single source of truth for how each value looks ───────────────────
709
+ DASH = "[grey50]—[/]"
710
+
711
+
712
+ def status_badge(iface: Interface) -> str:
713
+ if iface.status == "active":
714
+ return "[green]● active[/]"
715
+ if iface.status == "inactive":
716
+ return "[red]● inactive[/]"
717
+ return "[green]● up[/]" if iface.is_up else "[grey50]● down[/]"
718
+
719
+
720
+ def _color_kv_line(line: str, sep: str = " ") -> str:
721
+ """Color a verbatim multi-field line (bridge config) into key/value chunks."""
722
+ chunks = []
723
+ for k, v in kv_pairs(line):
724
+ if k and v:
725
+ chunks.append(f"[cyan]{k}[/] {v}")
726
+ elif k:
727
+ chunks.append(f"[bold]{k}[/]")
728
+ else:
729
+ chunks.append(v)
730
+ return sep.join(chunks) or f"[dim]{line}[/]"
731
+
732
+
733
+ def inet_detail(x: Inet) -> str:
734
+ val = f"[bold green]{x.addr}[/]"
735
+ if x.netmask:
736
+ val += f" netmask {x.netmask}"
737
+ if x.broadcast:
738
+ val += f" broadcast {x.broadcast}"
739
+ ann = [a for a in (ip_scope(x.addr), ipv4_net_info(x)) if a]
740
+ if ann:
741
+ val += " [dim]· " + " · ".join(ann) + "[/]"
742
+ return val
743
+
744
+
745
+ # IPv6 attributes map to fixed display columns so they line up vertically.
746
+ _V6_SLOTS = ("state", "config", "source")
747
+ _V6_ATTR_SLOT = {
748
+ "deprecated": "state",
749
+ "detached": "state",
750
+ "tentative": "state",
751
+ "duplicated": "state",
752
+ "optimistic": "state",
753
+ "autoconf": "config",
754
+ "secured": "source",
755
+ "temporary": "source",
756
+ "dynamic": "source",
757
+ }
758
+
759
+
760
+ @dataclass
761
+ class V6Layout:
762
+ """Column widths for aligning a single interface's IPv6 rows."""
763
+
764
+ addr_w: int = 0
765
+ slot_w: dict[str, int] = field(default_factory=lambda: {s: 0 for s in _V6_SLOTS})
766
+ extra_w: int = 0
767
+
768
+
769
+ def _v6_slotted(attrs: tuple[str, ...]) -> tuple[dict[str, str], list[str]]:
770
+ slots: dict[str, list[str]] = {s: [] for s in _V6_SLOTS}
771
+ extra: list[str] = []
772
+ for a in attrs:
773
+ slot = _V6_ATTR_SLOT.get(a)
774
+ (slots[slot] if slot else extra).append(a)
775
+ return {s: " ".join(v) for s, v in slots.items()}, extra
776
+
777
+
778
+ def plan_inet6(items: list[Inet6]) -> V6Layout:
779
+ layout = V6Layout()
780
+ for x in items:
781
+ addr = f"{x.addr}/{x.prefixlen}" if x.prefixlen else x.addr
782
+ layout.addr_w = max(layout.addr_w, len(addr))
783
+ slots, extra = _v6_slotted(x.attrs)
784
+ for s in _V6_SLOTS:
785
+ layout.slot_w[s] = max(layout.slot_w[s], len(slots[s]))
786
+ layout.extra_w = max(layout.extra_w, len(" ".join(extra)))
787
+ return layout
788
+
789
+
790
+ def _pad(markup: str, visible: int, width: int) -> str:
791
+ """Right-pad `markup` to `width` columns, counting only `visible` chars."""
792
+ return markup + " " * max(0, width - visible)
793
+
794
+
795
+ def fmt_inet6_aligned(x: Inet6, layout: V6Layout) -> str:
796
+ addr = f"{x.addr}/{x.prefixlen}" if x.prefixlen else x.addr
797
+ parts = [_pad(f"[bold magenta]{addr}[/]", len(addr), layout.addr_w)]
798
+ slots, extra = _v6_slotted(x.attrs)
799
+ for s in _V6_SLOTS:
800
+ if layout.slot_w[s]:
801
+ v = slots[s]
802
+ parts.append(_pad(f"[yellow]{v}[/]" if v else "", len(v), layout.slot_w[s]))
803
+ if layout.extra_w:
804
+ ex = " ".join(extra)
805
+ parts.append(_pad(f"[yellow]{ex}[/]" if ex else "", len(ex), layout.extra_w))
806
+ line = " ".join(parts)
807
+ tail = [t for t in (ip_scope(x.addr), "EUI-64" if is_eui64(x.addr) else "") if t]
808
+ if tail:
809
+ line += " [dim]· " + " · ".join(tail) + "[/]"
810
+ return line
811
+
812
+
813
+ def ether_value(iface: Interface) -> str:
814
+ """MAC + (kind · vendor) + shared-with note."""
815
+ if not iface.ether:
816
+ return ""
817
+ out = f"[yellow]{iface.ether}[/]"
818
+ tags = [t for t in (mac_kind(iface.ether), mac_vendor(iface.ether)) if t]
819
+ if tags:
820
+ out += " [dim](" + " · ".join(tags) + ")[/]"
821
+ if iface.shares_mac_with:
822
+ out += " [dim]· shared with " + " ".join(iface.shares_mac_with) + "[/]"
823
+ return out
824
+
825
+
826
+ def gateway_value(iface: Interface) -> str:
827
+ if not iface.gateway:
828
+ return ""
829
+ out = iface.gateway
830
+ if iface.gw_latency_ms >= 0:
831
+ out += f" [dim]· {iface.gw_latency_ms:.1f} ms[/]"
832
+ elif iface.gw_loss:
833
+ out += " [red]· unreachable[/]"
834
+ return out
835
+
836
+
837
+ def dhcp_value(iface: Interface) -> str:
838
+ if not iface.dhcp_server:
839
+ return ""
840
+ lease = humanize_secs(iface.dhcp_lease)
841
+ return iface.dhcp_server + (f" [dim]· lease {lease}[/]" if lease else "")
842
+
843
+
844
+ def bandwidth_rows(iface: Interface) -> list[tuple[str, str]]:
845
+ """1h throughput sparklines + link-utilization gauge (only with --record data)."""
846
+ rows: list[tuple[str, str]] = []
847
+ if iface.rx_hist:
848
+ rows.append(
849
+ (
850
+ "1h in",
851
+ sparkline(iface.rx_hist)
852
+ + f" [dim]peak {humanize_rate(max(iface.rx_hist))}[/]",
853
+ )
854
+ )
855
+ if iface.tx_hist:
856
+ rows.append(
857
+ (
858
+ "1h out",
859
+ sparkline(iface.tx_hist)
860
+ + f" [dim]peak {humanize_rate(max(iface.tx_hist))}[/]",
861
+ )
862
+ )
863
+ spd = link_mbps(iface.media)
864
+ if iface.rx_hist and iface.tx_hist and spd:
865
+ frac = max(iface.rx_hist[-1], iface.tx_hist[-1]) * 8 / (spd * 1e6)
866
+ rows.append(
867
+ ("link use", f"{bar(frac)} [dim]{frac * 100:.0f}% of {spd} Mbps[/]")
868
+ )
869
+ return rows
870
+
871
+
872
+ def interface_fields(iface: Interface) -> list[tuple[str, str]]:
873
+ """Canonical ordered (label, styled-value) rows for the row-based layouts."""
874
+ rows: list[tuple[str, str]] = []
875
+
876
+ def add(label: str, value: str) -> None:
877
+ if value:
878
+ rows.append((label, value))
879
+
880
+ add("mtu", iface.mtu + mtu_note(iface.mtu) if iface.mtu else "")
881
+ add("flags", " ".join(iface.flags))
882
+ add("options", " ".join(iface.options))
883
+ add("ether", ether_value(iface))
884
+ for v4 in iface.inet:
885
+ add("inet", inet_detail(v4))
886
+ v6plan = plan_inet6(iface.inet6)
887
+ for v6 in iface.inet6:
888
+ add("inet6", fmt_inet6_aligned(v6, v6plan))
889
+ add("nd6 options", " ".join(iface.nd6))
890
+ add("media", iface.media + media_note(iface.media) if iface.media else "")
891
+ add("dns", " ".join(iface.dns)) # Tier 2 (--live)
892
+ add("search", " ".join(iface.search_domains))
893
+ add("dhcp", dhcp_value(iface))
894
+ add("gateway", gateway_value(iface))
895
+ add("traffic", traffic_value(iface))
896
+ errs = errors_value(iface)
897
+ add("errors", f"[red]{errs}[/]" if errs else "")
898
+ for label, value in bandwidth_rows(iface):
899
+ add(label, value)
900
+ for i, line in enumerate(iface.other):
901
+ add("bridge" if i == 0 else "", _color_kv_line(line, sep=" "))
902
+ return rows
903
+
904
+
905
+ # compact formatters for the column-oriented table layout
906
+ def inet_compact(x: Inet) -> str:
907
+ bits = x.netmask.split("/")[-1] if "/" in x.netmask else ""
908
+ s = f"{x.addr}/{bits}" if bits else x.addr
909
+ if x.broadcast:
910
+ s += f" [dim]bcast {x.broadcast}[/]"
911
+ return s
912
+
913
+
914
+ def inet6_compact(x: Inet6) -> str:
915
+ s = f"{x.addr}/{x.prefixlen}" if x.prefixlen else x.addr
916
+ if x.attrs:
917
+ s += " [yellow]" + " ".join(x.attrs) + "[/]"
918
+ return s
919
+
920
+
921
+ def details_cell(iface: Interface) -> str:
922
+ """flags / options / nd6 / media / bridge-config as labeled lines (table view)."""
923
+ lines = []
924
+ if iface.flags:
925
+ lines.append("[dim cyan]flags[/] [green]" + " ".join(iface.flags) + "[/]")
926
+ if iface.options:
927
+ lines.append("[dim cyan]opts [/] " + " ".join(iface.options))
928
+ if iface.nd6:
929
+ lines.append("[dim cyan]nd6 [/] " + " ".join(iface.nd6))
930
+ if iface.media:
931
+ lines.append("[dim cyan]media[/] " + iface.media + media_note(iface.media))
932
+ if iface.dns:
933
+ lines.append("[dim cyan]dns [/] " + " ".join(iface.dns))
934
+ if iface.search_domains:
935
+ lines.append("[dim cyan]srch [/] " + " ".join(iface.search_domains))
936
+ if dhcp_value(iface):
937
+ lines.append("[dim cyan]dhcp [/] " + dhcp_value(iface))
938
+ if gateway_value(iface):
939
+ lines.append("[dim cyan]gw [/] " + gateway_value(iface))
940
+ if traffic_value(iface):
941
+ lines.append("[dim cyan]traf [/] " + traffic_value(iface))
942
+ if errors_value(iface):
943
+ lines.append("[dim cyan]err [/] [red]" + errors_value(iface) + "[/]")
944
+ for label, value in bandwidth_rows(iface):
945
+ lines.append(f"[dim cyan]{label:<5}[/] {value}")
946
+ lines += [_color_kv_line(line) for line in iface.other]
947
+ return "\n".join(lines) or DASH
948
+
949
+
950
+ def caption_suffix(iface: Interface) -> str:
951
+ """Role / friendly-name / default-route badge appended to an interface header."""
952
+ parts = []
953
+ label = iface.hw_port or iface.role
954
+ if label:
955
+ parts.append(f"[cyan]{label}[/]")
956
+ if iface.is_default:
957
+ parts.append("[green]default ↑[/]")
958
+ return (" · " + " · ".join(parts)) if parts else ""
959
+
960
+
961
+ # ── RENDER — strategies over the same model/formatters ─────────────────────────
962
+ def _console(non_tty_width: int) -> Console:
963
+ return Console(width=None if sys.stdout.isatty() else non_tty_width)
964
+
965
+
966
+ def render_boxed(interfaces: list[Interface]) -> None:
967
+ """Boxed table per interface; every box shares one width so borders align."""
968
+ console = _console(200)
969
+ all_rows = [interface_fields(iface) for iface in interfaces]
970
+ label_w = max(
971
+ (len(lbl) for rows in all_rows for lbl, _ in rows), default=len("nd6 options")
972
+ )
973
+ value_w = max(
974
+ (Text.from_markup(val).cell_len for rows in all_rows for _, val in rows),
975
+ default=1,
976
+ )
977
+
978
+ for iface, rows in zip(interfaces, all_rows):
979
+ table = Table(
980
+ box=box.ROUNDED,
981
+ show_header=False,
982
+ title=f"[bold blue]{iface.name}[/] {status_badge(iface)}"
983
+ f"{caption_suffix(iface)}",
984
+ title_justify="left",
985
+ )
986
+ table.add_column(
987
+ "Field", style="cyan", justify="right", width=label_w, no_wrap=True
988
+ )
989
+ table.add_column("Value", width=value_w, no_wrap=True)
990
+ for label, value in rows:
991
+ table.add_row(label, value)
992
+ console.print(table)
993
+
994
+
995
+ def render_records(interfaces: list[Interface]) -> None:
996
+ """Compact label/value blocks — width bounded by the longest line."""
997
+ console = _console(100)
998
+ all_rows = [interface_fields(iface) for iface in interfaces]
999
+ label_w = max((len(lbl) for rows in all_rows for lbl, _ in rows), default=1)
1000
+
1001
+ for i, (iface, rows) in enumerate(zip(interfaces, all_rows)):
1002
+ if i:
1003
+ console.print()
1004
+ mtu = f" · [dim]mtu {iface.mtu}[/]" if iface.mtu else ""
1005
+ console.print(
1006
+ f"[bold blue]{iface.name}[/] {status_badge(iface)}"
1007
+ f"{caption_suffix(iface)}{mtu}"
1008
+ )
1009
+ for label, value in rows:
1010
+ if label == "mtu": # already shown in the header
1011
+ continue
1012
+ console.print(f" [cyan]{label:<{label_w}}[/] {value}")
1013
+
1014
+
1015
+ def render_table(interfaces: list[Interface]) -> None:
1016
+ """All interfaces and all fields in one wide table; cap width at 240."""
1017
+ cap = 240
1018
+ term = shutil.get_terminal_size((cap, 24)).columns if sys.stdout.isatty() else cap
1019
+ console = Console(width=min(term, cap))
1020
+
1021
+ table = Table(
1022
+ box=box.ROUNDED, header_style="bold cyan", title="ifconfig", show_lines=True
1023
+ )
1024
+ table.add_column("Interface", no_wrap=True)
1025
+ table.add_column("Status", no_wrap=True)
1026
+ table.add_column("MTU", justify="right", style="dim", no_wrap=True)
1027
+ table.add_column("MAC", style="yellow", no_wrap=True)
1028
+ table.add_column("IPv4", style="green", no_wrap=True)
1029
+ table.add_column("IPv6", style="magenta", no_wrap=True)
1030
+ table.add_column("Details")
1031
+
1032
+ for iface in interfaces:
1033
+ name_cell = f"[bold blue]{iface.name}[/]"
1034
+ sub = iface.hw_port or iface.role
1035
+ if sub:
1036
+ name_cell += f"\n[dim]{sub}[/]"
1037
+ if iface.is_default:
1038
+ name_cell += "\n[green]default ↑[/]"
1039
+ table.add_row(
1040
+ name_cell,
1041
+ status_badge(iface),
1042
+ iface.mtu or DASH,
1043
+ iface.ether or DASH,
1044
+ "\n".join(inet_compact(x) for x in iface.inet) or DASH,
1045
+ "\n".join(inet6_compact(x) for x in iface.inet6) or DASH,
1046
+ details_cell(iface),
1047
+ )
1048
+ console.print(table)
1049
+
1050
+
1051
+ def render_overview(interfaces: list[Interface]) -> None:
1052
+ """One-line summary above the layout (shown only when >1 interface)."""
1053
+ console = _console(120)
1054
+ up = sum(1 for i in interfaces if i.is_up)
1055
+ parts = [f"[bold]{len(interfaces)}[/] interfaces", f"[green]{up} up[/]"]
1056
+ default = next((i for i in interfaces if i.is_default), None)
1057
+ if default:
1058
+ gw = f" ({default.gateway})" if default.gateway else ""
1059
+ parts.append(f"default via [bold blue]{default.name}[/]{gw}")
1060
+ live = [i for i in interfaces if i.rx_bytes >= 0]
1061
+ if live:
1062
+ rx = sum(i.rx_bytes for i in live)
1063
+ tx = sum(i.tx_bytes for i in live)
1064
+ parts.append(f"↓ {humanize_bytes(rx)} ↑ {humanize_bytes(tx)}")
1065
+ console.print(" · ".join(parts))
1066
+ console.print()
1067
+
1068
+
1069
+ Renderer = Callable[[list[Interface]], None]
1070
+ RENDERERS: dict[str, Renderer] = {
1071
+ "boxed": render_boxed,
1072
+ "records": render_records,
1073
+ "table": render_table,
1074
+ }
1075
+
1076
+
1077
+ # ── EXPLAIN — flag/option glossary (--explain) ────────────────────────────────
1078
+ _GLOSSARY = {
1079
+ # flags
1080
+ "UP": "interface is up",
1081
+ "DOWN": "interface is down",
1082
+ "BROADCAST": "supports broadcast",
1083
+ "MULTICAST": "supports multicast",
1084
+ "LOOPBACK": "loopback interface",
1085
+ "POINTOPOINT": "point-to-point link",
1086
+ "RUNNING": "resources allocated",
1087
+ "SIMPLEX": "can't hear its own transmissions",
1088
+ "SMART": "managed by the system",
1089
+ "PROMISC": "promiscuous (sees all traffic)",
1090
+ "NOARP": "ARP disabled",
1091
+ # options (hardware offloads etc.)
1092
+ "RXCSUM": "hardware RX checksum offload",
1093
+ "TXCSUM": "hardware TX checksum offload",
1094
+ "TSO4": "TCP segmentation offload (IPv4)",
1095
+ "TSO6": "TCP segmentation offload (IPv6)",
1096
+ "LRO": "large receive offload",
1097
+ "VLAN_MTU": "VLAN-sized MTU support",
1098
+ "CHANNEL_IO": "multi-queue channel I/O",
1099
+ "PARTIAL_CSUM": "partial checksum offload",
1100
+ "ZEROINVERT_CSUM": "zero-invert checksum offload",
1101
+ "TXSTATUS": "TX completion status",
1102
+ "SW_TIMESTAMP": "software packet timestamping",
1103
+ # nd6
1104
+ "PERFORMNUD": "neighbor unreachability detection",
1105
+ "DAD": "duplicate address detection",
1106
+ "IFDISABLED": "IPv6 disabled on this interface",
1107
+ }
1108
+
1109
+
1110
+ def render_legend(interfaces: list[Interface]) -> None:
1111
+ console = Console(width=None if sys.stdout.isatty() else 100)
1112
+ sections = [
1113
+ ("flags", sorted({t for i in interfaces for t in i.flags})),
1114
+ ("options", sorted({t for i in interfaces for t in i.options})),
1115
+ ("nd6", sorted({t for i in interfaces for t in i.nd6})),
1116
+ ]
1117
+ console.print("\n[bold]Legend[/]")
1118
+ for title, tokens in sections:
1119
+ if not tokens:
1120
+ continue
1121
+ console.print(f"[dim cyan]{title}[/]")
1122
+ for tok in tokens:
1123
+ desc = _GLOSSARY.get(tok, "[dim](no description)[/]")
1124
+ console.print(f" [green]{tok:<16}[/] {desc}")
1125
+
1126
+
1127
+ # ── CLI ───────────────────────────────────────────────────────────────────────
1128
+ def _cli() -> int:
1129
+ p = argparse.ArgumentParser(
1130
+ prog="ifpretty",
1131
+ description="Prettify `ifconfig` output. Pipe ifconfig into me.",
1132
+ epilog="bandwidth graph: schedule `* * * * * %s --record` (cron/launchd) to "
1133
+ "sample counters; the 1h sparkline then appears automatically."
1134
+ % (shutil.which("ifpretty") or "ifpretty"),
1135
+ )
1136
+ g = p.add_mutually_exclusive_group()
1137
+ g.add_argument(
1138
+ "-r",
1139
+ "--records",
1140
+ dest="layout",
1141
+ action="store_const",
1142
+ const="records",
1143
+ help="compact label/value record layout",
1144
+ )
1145
+ g.add_argument(
1146
+ "-t",
1147
+ "--table",
1148
+ dest="layout",
1149
+ action="store_const",
1150
+ const="table",
1151
+ help="single wide table, all fields as columns",
1152
+ )
1153
+ p.add_argument(
1154
+ "-l",
1155
+ "--live",
1156
+ action="store_true",
1157
+ help="enrich with live system data (macOS: route, traffic, "
1158
+ "friendly names, DNS, DHCP lease)",
1159
+ )
1160
+ p.add_argument(
1161
+ "-c",
1162
+ "--check",
1163
+ action="store_true",
1164
+ help="ping the default gateway and report latency (macOS)",
1165
+ )
1166
+ p.add_argument(
1167
+ "--record",
1168
+ action="store_true",
1169
+ help="sample byte counters into the history file and exit "
1170
+ "(run periodically via cron to feed the 1h graph)",
1171
+ )
1172
+ p.add_argument(
1173
+ "-x",
1174
+ "--explain",
1175
+ action="store_true",
1176
+ help="append a legend explaining the flags/options seen",
1177
+ )
1178
+ p.set_defaults(layout="boxed")
1179
+ args = p.parse_args()
1180
+
1181
+ if args.record: # no stdin needed; samples counters and exits
1182
+ return record_sample()
1183
+
1184
+ data = sys.stdin.read()
1185
+ if not data.strip():
1186
+ print("ifpretty: no input (pipe `ifconfig` into me)", file=sys.stderr)
1187
+ return 1
1188
+
1189
+ interfaces = parse(data)
1190
+ if args.live:
1191
+ enrich_live(interfaces)
1192
+ if args.check:
1193
+ enrich_check(interfaces)
1194
+ derive_shared_macs(interfaces) # Tier 1: always-on, deterministic
1195
+ enrich_history(interfaces) # 1h bandwidth from the --record datastore
1196
+ if len(interfaces) > 1:
1197
+ render_overview(interfaces)
1198
+ RENDERERS[args.layout](interfaces)
1199
+ if args.explain:
1200
+ render_legend(interfaces)
1201
+ return 0
1202
+
1203
+
1204
+ def main() -> int:
1205
+ """Console-script entry point: run and translate signals to exit codes."""
1206
+ try:
1207
+ return _cli()
1208
+ except BrokenPipeError:
1209
+ return 0
1210
+ except KeyboardInterrupt:
1211
+ return 130
1212
+
1213
+
1214
+ if __name__ == "__main__":
1215
+ sys.exit(main())
Binary file
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: ifpretty
3
+ Version: 0.1.0
4
+ Summary: Prettify and enrich ifconfig output: aligned tables, IP/MAC/scope annotations, OUI vendor lookup, live DNS/DHCP/route, and a 1h bandwidth graph.
5
+ Author-email: iklo <tiktachack@gmail.com>
6
+ License: MIT
7
+ Keywords: ifconfig,network,networking,cli,terminal,rich,macos
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: System Administrators
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: MacOS :: MacOS X
12
+ Classifier: Operating System :: POSIX
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: System :: Networking
16
+ Classifier: Topic :: System :: Monitoring
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: rich>=13
21
+ Dynamic: license-file
22
+
23
+ # ifpretty
24
+
25
+ Prettify and **enrich** `ifconfig` output — aligned tables, color, decoded flags,
26
+ IP/MAC/scope annotations, OUI vendor lookup, live DNS/DHCP/route, and a 1‑hour
27
+ bandwidth graph. Built on [rich](https://github.com/Textualize/rich); macOS‑focused.
28
+
29
+ ```
30
+ ifconfig | ifpretty
31
+ ```
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ pip install ifpretty
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```sh
42
+ ifconfig | ifpretty # boxed table per interface (default), borders aligned
43
+ ifconfig | ifpretty --records # compact label/value record layout
44
+ ifconfig | ifpretty --table # single wide table, all fields as columns (<=240)
45
+ ifconfig | ifpretty --live # + route, traffic, friendly names, DNS, DHCP (macOS)
46
+ ifconfig | ifpretty --check # + ping the default gateway for latency (macOS)
47
+ ifconfig | ifpretty --explain # append a legend explaining the flags/options seen
48
+ ifconfig en0 | ifpretty
49
+ ```
50
+
51
+ It reads raw `ifconfig` on **stdin**, so you stay in control of which interfaces
52
+ to show.
53
+
54
+ ## What it adds
55
+
56
+ **Always on (offline, deterministic):**
57
+
58
+ - Per‑interface **role** (loopback / Ethernet·Wi‑Fi / VPN·tunnel / bridge / …).
59
+ - **IP scope** for every address — loopback / link‑local / ULA / private / global —
60
+ plus IPv4 network + usable‑host count.
61
+ - **MAC** kind (universal vs locally‑administered), **OUI vendor** (bundled IEEE
62
+ registry, no network), shared‑MAC notes (e.g. bridge members), and EUI‑64 detection.
63
+ - **Media** speed/duplex with a half‑duplex ⚠ warning; jumbo / sub‑1280 MTU notes.
64
+ - Decoded flag/option/nd6 bitfields; aligned IPv6 attribute columns.
65
+
66
+ **`--live` (macOS, cross‑references the system):**
67
+
68
+ - Default route + gateway, friendly hardware‑port names, per‑interface DNS and
69
+ search domains, DHCP server + lease, traffic counters (bytes + packets) and
70
+ error/collision counts.
71
+
72
+ **`--check`:** pings the default gateway and reports latency.
73
+
74
+ **1‑hour bandwidth graph:** schedule the recorder, then a Unicode sparkline of
75
+ the last hour appears automatically:
76
+
77
+ ```sh
78
+ # once, e.g. in crontab -e
79
+ * * * * * /usr/local/bin/ifpretty --record
80
+ ```
81
+
82
+ ```
83
+ 1h in ▁▁▁▂▄█▅▂▁▁▁▁ peak 10.0 Mbps
84
+ 1h out ▁▂▂▄▆█▄▂▂▁▁▁ peak 533.3 Kbps
85
+ ```
86
+
87
+ History is stored at `~/.cache/ifpretty/samples.csv` (override with
88
+ `IFPRETTY_HISTORY`).
89
+
90
+ ## Notes
91
+
92
+ - Colors auto‑disable when piped; force with `CLICOLOR_FORCE=1`, disable with `NO_COLOR=1`.
93
+ - `--live` / `--check` / `--record` use macOS tools (`route`, `netstat`,
94
+ `networksetup`, `scutil`, `ipconfig`, `ping`) and no‑op elsewhere.
95
+
96
+ ## License
97
+
98
+ MIT
@@ -0,0 +1,10 @@
1
+ ifpretty/__init__.py,sha256=PTN2KgYem_jt0VzqfftwuU6ncCRzC-Yv-2zlLpAjMDA,138
2
+ ifpretty/__main__.py,sha256=uZnYIf4DigA8XAM1rpBcnl_kZMNAMlS6Q_rnM4UMXCo,119
3
+ ifpretty/cli.py,sha256=f9LyToh9MPbxKebmeZxFB3KGuzkCuTRFVKTVgEY0_DM,41740
4
+ ifpretty/data/oui.tsv.gz,sha256=SsoMSzpQCkfd5iA2Gu-aGpPMzhY7gpIg0h6o1q0o9b0,383392
5
+ ifpretty-0.1.0.dist-info/licenses/LICENSE,sha256=BmnKRIJzpO5vnb3_d58OfVyK6hLXjgMoRoGcNHkHVsc,1061
6
+ ifpretty-0.1.0.dist-info/METADATA,sha256=V5AEmhmGJM43ZkRiliLYi7AeyYfDktz4O9t1pG3bTr4,3435
7
+ ifpretty-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ ifpretty-0.1.0.dist-info/entry_points.txt,sha256=pXnVylPg1NGTJ3tw5KhEqEuOpoAq9B6Vo2wvfDA6sOU,47
9
+ ifpretty-0.1.0.dist-info/top_level.txt,sha256=DQdGS2WYCG9iSqYevTVLX__L_4hcuEKfAt10Sm6Hm6M,9
10
+ ifpretty-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ifpretty = ifpretty.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iklo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ifpretty