router-cli 0.1.0__py3-none-any.whl → 0.3.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.
- router_cli/__init__.py +1 -1
- router_cli/client.py +47 -610
- router_cli/commands.py +227 -0
- router_cli/config.py +158 -28
- router_cli/display.py +191 -0
- router_cli/formatters.py +368 -0
- router_cli/main.py +49 -635
- router_cli/models.py +165 -0
- router_cli/parser.py +521 -0
- {router_cli-0.1.0.dist-info → router_cli-0.3.0.dist-info}/METADATA +48 -6
- router_cli-0.3.0.dist-info/RECORD +14 -0
- router_cli-0.1.0.dist-info/RECORD +0 -9
- {router_cli-0.1.0.dist-info → router_cli-0.3.0.dist-info}/WHEEL +0 -0
- {router_cli-0.1.0.dist-info → router_cli-0.3.0.dist-info}/entry_points.txt +0 -0
router_cli/commands.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Command handlers for the router CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
|
|
7
|
+
from .client import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
ConnectionError,
|
|
10
|
+
HTTPError,
|
|
11
|
+
RouterClient,
|
|
12
|
+
RouterError,
|
|
13
|
+
)
|
|
14
|
+
from .config import KnownDevices
|
|
15
|
+
from .display import spinner
|
|
16
|
+
from .formatters import (
|
|
17
|
+
format_clients,
|
|
18
|
+
format_dhcp,
|
|
19
|
+
format_logs,
|
|
20
|
+
format_overview,
|
|
21
|
+
format_routes,
|
|
22
|
+
format_stats,
|
|
23
|
+
format_status,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _handle_error(e: Exception, json_output: bool = False) -> int:
|
|
28
|
+
"""Handle exceptions and print user-friendly error messages.
|
|
29
|
+
|
|
30
|
+
Returns the exit code to use.
|
|
31
|
+
"""
|
|
32
|
+
error_info = {"error": str(e), "type": type(e).__name__}
|
|
33
|
+
|
|
34
|
+
if isinstance(e, AuthenticationError):
|
|
35
|
+
error_info["hint"] = "Check your username/password in the config file."
|
|
36
|
+
error_info["code"] = 2
|
|
37
|
+
elif isinstance(e, ConnectionError):
|
|
38
|
+
error_info["hint"] = "Ensure the router is reachable and the IP is correct."
|
|
39
|
+
error_info["code"] = 3
|
|
40
|
+
elif isinstance(e, HTTPError):
|
|
41
|
+
error_info["code"] = 4
|
|
42
|
+
if e.status_code == 503:
|
|
43
|
+
error_info["hint"] = "The router may be busy. Wait a moment and try again."
|
|
44
|
+
error_info["status_code"] = e.status_code
|
|
45
|
+
elif isinstance(e, RouterError):
|
|
46
|
+
error_info["code"] = 1
|
|
47
|
+
else:
|
|
48
|
+
error_info["code"] = 1
|
|
49
|
+
|
|
50
|
+
if json_output:
|
|
51
|
+
print(json.dumps(error_info, indent=2), file=sys.stderr)
|
|
52
|
+
else:
|
|
53
|
+
if isinstance(e, AuthenticationError):
|
|
54
|
+
print(f"Authentication error: {e}", file=sys.stderr)
|
|
55
|
+
print(
|
|
56
|
+
" Hint: Check your username/password in the config file.",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
elif isinstance(e, ConnectionError):
|
|
60
|
+
print(f"Connection error: {e}", file=sys.stderr)
|
|
61
|
+
print(
|
|
62
|
+
" Hint: Ensure the router is reachable and the IP is correct.",
|
|
63
|
+
file=sys.stderr,
|
|
64
|
+
)
|
|
65
|
+
elif isinstance(e, HTTPError):
|
|
66
|
+
print(f"Router error: {e}", file=sys.stderr)
|
|
67
|
+
if e.status_code == 503:
|
|
68
|
+
print(
|
|
69
|
+
" Hint: The router may be busy. Wait a moment and try again.",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
elif isinstance(e, RouterError):
|
|
73
|
+
print(f"Router error: {e}", file=sys.stderr)
|
|
74
|
+
else:
|
|
75
|
+
print(f"Unexpected error ({type(e).__name__}): {e}", file=sys.stderr)
|
|
76
|
+
|
|
77
|
+
return error_info["code"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _to_json(obj) -> str:
|
|
81
|
+
"""Convert dataclass or list of dataclasses to JSON."""
|
|
82
|
+
if isinstance(obj, list):
|
|
83
|
+
return json.dumps([asdict(item) for item in obj], indent=2)
|
|
84
|
+
return json.dumps(asdict(obj), indent=2)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_status(client: RouterClient, json_output: bool = False) -> int:
|
|
88
|
+
"""Execute status command."""
|
|
89
|
+
try:
|
|
90
|
+
with spinner("Fetching router status..."):
|
|
91
|
+
status = client.get_status()
|
|
92
|
+
if json_output:
|
|
93
|
+
# Convert WANConnection objects to dicts for JSON
|
|
94
|
+
data = asdict(status)
|
|
95
|
+
print(json.dumps(data, indent=2))
|
|
96
|
+
else:
|
|
97
|
+
print(format_status(status))
|
|
98
|
+
return 0
|
|
99
|
+
except Exception as e:
|
|
100
|
+
return _handle_error(e, json_output)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_reboot(client: RouterClient) -> int:
|
|
104
|
+
"""Execute reboot command."""
|
|
105
|
+
try:
|
|
106
|
+
with spinner("Sending reboot command..."):
|
|
107
|
+
client.reboot()
|
|
108
|
+
print("Reboot command sent successfully.")
|
|
109
|
+
print("The router will restart in a few seconds.")
|
|
110
|
+
return 0
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return _handle_error(e)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_clients(
|
|
116
|
+
client: RouterClient, known_devices: KnownDevices, json_output: bool = False
|
|
117
|
+
) -> int:
|
|
118
|
+
"""Execute clients command."""
|
|
119
|
+
try:
|
|
120
|
+
with spinner("Fetching wireless clients..."):
|
|
121
|
+
clients = client.get_wireless_clients()
|
|
122
|
+
if json_output:
|
|
123
|
+
print(_to_json(clients))
|
|
124
|
+
else:
|
|
125
|
+
print(format_clients(clients, known_devices))
|
|
126
|
+
return 0
|
|
127
|
+
except Exception as e:
|
|
128
|
+
return _handle_error(e, json_output)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_dhcp(
|
|
132
|
+
client: RouterClient, known_devices: KnownDevices, json_output: bool = False
|
|
133
|
+
) -> int:
|
|
134
|
+
"""Execute dhcp command."""
|
|
135
|
+
try:
|
|
136
|
+
with spinner("Fetching DHCP leases..."):
|
|
137
|
+
leases = client.get_dhcp_leases()
|
|
138
|
+
if json_output:
|
|
139
|
+
print(_to_json(leases))
|
|
140
|
+
else:
|
|
141
|
+
print(format_dhcp(leases, known_devices))
|
|
142
|
+
return 0
|
|
143
|
+
except Exception as e:
|
|
144
|
+
return _handle_error(e, json_output)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_routes(client: RouterClient, json_output: bool = False) -> int:
|
|
148
|
+
"""Execute routes command."""
|
|
149
|
+
try:
|
|
150
|
+
with spinner("Fetching routing table..."):
|
|
151
|
+
routes = client.get_routes()
|
|
152
|
+
if json_output:
|
|
153
|
+
print(_to_json(routes))
|
|
154
|
+
else:
|
|
155
|
+
print(format_routes(routes))
|
|
156
|
+
return 0
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return _handle_error(e, json_output)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_stats(client: RouterClient, json_output: bool = False) -> int:
|
|
162
|
+
"""Execute stats command."""
|
|
163
|
+
try:
|
|
164
|
+
with spinner("Fetching network statistics..."):
|
|
165
|
+
stats = client.get_statistics()
|
|
166
|
+
if json_output:
|
|
167
|
+
print(_to_json(stats))
|
|
168
|
+
else:
|
|
169
|
+
print(format_stats(stats))
|
|
170
|
+
return 0
|
|
171
|
+
except Exception as e:
|
|
172
|
+
return _handle_error(e, json_output)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cmd_logs(
|
|
176
|
+
client: RouterClient,
|
|
177
|
+
tail: int | None = None,
|
|
178
|
+
level: str | None = None,
|
|
179
|
+
json_output: bool = False,
|
|
180
|
+
) -> int:
|
|
181
|
+
"""Execute logs command."""
|
|
182
|
+
try:
|
|
183
|
+
with spinner("Fetching system logs..."):
|
|
184
|
+
logs = client.get_logs()
|
|
185
|
+
|
|
186
|
+
# Filter by severity level if specified
|
|
187
|
+
if level:
|
|
188
|
+
level_upper = level.upper()
|
|
189
|
+
logs = [log for log in logs if log.severity.upper() == level_upper]
|
|
190
|
+
|
|
191
|
+
# Limit to last N entries if tail specified
|
|
192
|
+
if tail and tail > 0:
|
|
193
|
+
logs = logs[-tail:]
|
|
194
|
+
|
|
195
|
+
if json_output:
|
|
196
|
+
print(_to_json(logs))
|
|
197
|
+
else:
|
|
198
|
+
print(format_logs(logs))
|
|
199
|
+
return 0
|
|
200
|
+
except Exception as e:
|
|
201
|
+
return _handle_error(e, json_output)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_overview(
|
|
205
|
+
client: RouterClient, known_devices: KnownDevices, json_output: bool = False
|
|
206
|
+
) -> int:
|
|
207
|
+
"""Execute overview command."""
|
|
208
|
+
try:
|
|
209
|
+
with spinner("Fetching router overview..."):
|
|
210
|
+
status = client.get_status()
|
|
211
|
+
clients = client.get_wireless_clients()
|
|
212
|
+
leases = client.get_dhcp_leases()
|
|
213
|
+
stats = client.get_statistics()
|
|
214
|
+
|
|
215
|
+
if json_output:
|
|
216
|
+
data = {
|
|
217
|
+
"status": asdict(status),
|
|
218
|
+
"wireless_clients": [asdict(c) for c in clients],
|
|
219
|
+
"dhcp_leases": [asdict(lease) for lease in leases],
|
|
220
|
+
"statistics": asdict(stats),
|
|
221
|
+
}
|
|
222
|
+
print(json.dumps(data, indent=2))
|
|
223
|
+
else:
|
|
224
|
+
print(format_overview(status, clients, leases, stats, known_devices))
|
|
225
|
+
return 0
|
|
226
|
+
except Exception as e:
|
|
227
|
+
return _handle_error(e, json_output)
|
router_cli/config.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Configuration loading for router CLI."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import sys
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -9,6 +10,11 @@ else:
|
|
|
9
10
|
import tomli as tomllib
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
# Default configuration values
|
|
14
|
+
DEFAULT_IP = "192.168.1.1"
|
|
15
|
+
DEFAULT_USERNAME = "admin"
|
|
16
|
+
|
|
17
|
+
|
|
12
18
|
def get_config_paths() -> list[Path]:
|
|
13
19
|
"""Return list of config file paths in order of priority."""
|
|
14
20
|
return [
|
|
@@ -27,40 +33,164 @@ def _load_config_file() -> dict | None:
|
|
|
27
33
|
return None
|
|
28
34
|
|
|
29
35
|
|
|
30
|
-
def load_config(
|
|
31
|
-
|
|
36
|
+
def load_config(
|
|
37
|
+
cli_ip: str | None = None,
|
|
38
|
+
cli_user: str | None = None,
|
|
39
|
+
cli_pass: str | None = None,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Load configuration with priority: CLI args > env vars > config file > defaults.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
cli_ip: IP address from CLI argument (highest priority)
|
|
45
|
+
cli_user: Username from CLI argument
|
|
46
|
+
cli_pass: Password from CLI argument
|
|
32
47
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
Environment variables:
|
|
49
|
+
ROUTER_IP: Router IP address
|
|
50
|
+
ROUTER_USER: Username for authentication
|
|
51
|
+
ROUTER_PASS: Password for authentication
|
|
52
|
+
|
|
53
|
+
Config file locations (in order of priority):
|
|
54
|
+
1. ./config.toml (current directory)
|
|
55
|
+
2. ~/.config/router/config.toml
|
|
56
|
+
3. /etc/router/config.toml
|
|
37
57
|
"""
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
# Start with defaults
|
|
59
|
+
config: dict[str, str] = {
|
|
60
|
+
"ip": DEFAULT_IP,
|
|
61
|
+
"username": DEFAULT_USERNAME,
|
|
62
|
+
"password": "",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Layer 1: Config file (lowest priority for file-based config)
|
|
66
|
+
file_config = _load_config_file()
|
|
67
|
+
if file_config is not None:
|
|
68
|
+
router_config = file_config.get("router", {})
|
|
69
|
+
config.update(router_config)
|
|
70
|
+
|
|
71
|
+
# Layer 2: Environment variables
|
|
72
|
+
if os.environ.get("ROUTER_IP"):
|
|
73
|
+
config["ip"] = os.environ["ROUTER_IP"]
|
|
74
|
+
if os.environ.get("ROUTER_USER"):
|
|
75
|
+
config["username"] = os.environ["ROUTER_USER"]
|
|
76
|
+
if os.environ.get("ROUTER_PASS"):
|
|
77
|
+
config["password"] = os.environ["ROUTER_PASS"]
|
|
78
|
+
|
|
79
|
+
# Layer 3: CLI arguments (highest priority)
|
|
80
|
+
if cli_ip:
|
|
81
|
+
config["ip"] = cli_ip
|
|
82
|
+
if cli_user:
|
|
83
|
+
config["username"] = cli_user
|
|
84
|
+
if cli_pass:
|
|
85
|
+
config["password"] = cli_pass
|
|
86
|
+
|
|
87
|
+
# Require password from some source
|
|
88
|
+
if not config.get("password"):
|
|
89
|
+
# Check if we have a config file - if not, show helpful error
|
|
90
|
+
if file_config is None and not os.environ.get("ROUTER_PASS"):
|
|
91
|
+
raise FileNotFoundError(
|
|
92
|
+
"No password configured. Either:\n"
|
|
93
|
+
" 1. Create ~/.config/router/config.toml with:\n"
|
|
94
|
+
" [router]\n"
|
|
95
|
+
' ip = "192.168.1.1"\n'
|
|
96
|
+
' username = "admin"\n'
|
|
97
|
+
' password = "your_password"\n'
|
|
98
|
+
"\n"
|
|
99
|
+
" 2. Set environment variables:\n"
|
|
100
|
+
" export ROUTER_PASS=your_password\n"
|
|
101
|
+
"\n"
|
|
102
|
+
" 3. Use CLI flags:\n"
|
|
103
|
+
" router --pass your_password status"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return config
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_mac_address(identifier: str) -> bool:
|
|
110
|
+
"""Check if a string looks like a MAC address.
|
|
111
|
+
|
|
112
|
+
MAC addresses contain colons and are in format XX:XX:XX:XX:XX:XX
|
|
113
|
+
"""
|
|
114
|
+
import re
|
|
115
|
+
|
|
116
|
+
# Match typical MAC address formats (with colons)
|
|
117
|
+
return bool(re.match(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", identifier))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class KnownDevices:
|
|
121
|
+
"""Container for known devices, supporting lookup by MAC or hostname.
|
|
122
|
+
|
|
123
|
+
This class supports devices that use random MAC addresses by allowing
|
|
124
|
+
hostname-based identification in addition to MAC-based.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
by_mac: dict[str, str] | None = None,
|
|
130
|
+
by_hostname: dict[str, str] | None = None,
|
|
131
|
+
):
|
|
132
|
+
self.by_mac: dict[str, str] = by_mac or {}
|
|
133
|
+
self.by_hostname: dict[str, str] = by_hostname or {}
|
|
134
|
+
|
|
135
|
+
def get_alias(self, mac: str, hostname: str = "") -> str | None:
|
|
136
|
+
"""Get alias for a device by MAC or hostname.
|
|
137
|
+
|
|
138
|
+
MAC lookup takes priority. Returns None if device is not known.
|
|
139
|
+
"""
|
|
140
|
+
# First try MAC lookup (normalized to uppercase)
|
|
141
|
+
alias = self.by_mac.get(mac.upper())
|
|
142
|
+
if alias:
|
|
143
|
+
return alias
|
|
144
|
+
|
|
145
|
+
# Then try hostname lookup (case-insensitive)
|
|
146
|
+
if hostname:
|
|
147
|
+
alias = self.by_hostname.get(hostname.lower())
|
|
148
|
+
if alias:
|
|
149
|
+
return alias
|
|
150
|
+
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
def is_known(self, mac: str, hostname: str = "") -> bool:
|
|
154
|
+
"""Check if a device is known by MAC or hostname."""
|
|
155
|
+
return self.get_alias(mac, hostname) is not None
|
|
156
|
+
|
|
157
|
+
def get(self, key: str, default: str | None = None) -> str | None:
|
|
158
|
+
"""Dict-like get for backward compatibility (MAC lookup only)."""
|
|
159
|
+
return self.by_mac.get(key, default)
|
|
160
|
+
|
|
161
|
+
def __contains__(self, key: str) -> bool:
|
|
162
|
+
"""Dict-like contains for backward compatibility (MAC lookup only)."""
|
|
163
|
+
return key in self.by_mac
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def load_known_devices() -> KnownDevices:
|
|
56
167
|
"""Load known devices from config file.
|
|
57
168
|
|
|
58
|
-
Returns a
|
|
169
|
+
Returns a KnownDevices object supporting lookup by MAC address or hostname.
|
|
170
|
+
|
|
171
|
+
Config format:
|
|
172
|
+
[known_devices]
|
|
173
|
+
"AA:BB:CC:DD:EE:FF" = "My Phone" # MAC-based (for stable MACs)
|
|
174
|
+
"android-abc123" = "John's Pixel" # Hostname-based (for random MACs)
|
|
175
|
+
|
|
176
|
+
MAC addresses are identified by format (XX:XX:XX:XX:XX:XX with colons).
|
|
177
|
+
Any other identifier is treated as a hostname.
|
|
59
178
|
"""
|
|
60
179
|
config = _load_config_file()
|
|
61
180
|
if config is None:
|
|
62
|
-
return
|
|
181
|
+
return KnownDevices()
|
|
63
182
|
|
|
64
183
|
known = config.get("known_devices", {})
|
|
65
|
-
|
|
66
|
-
|
|
184
|
+
|
|
185
|
+
by_mac: dict[str, str] = {}
|
|
186
|
+
by_hostname: dict[str, str] = {}
|
|
187
|
+
|
|
188
|
+
for identifier, alias in known.items():
|
|
189
|
+
if _is_mac_address(identifier):
|
|
190
|
+
# MAC address - normalize to uppercase
|
|
191
|
+
by_mac[identifier.upper()] = alias
|
|
192
|
+
else:
|
|
193
|
+
# Hostname - normalize to lowercase for case-insensitive matching
|
|
194
|
+
by_hostname[identifier.lower()] = alias
|
|
195
|
+
|
|
196
|
+
return KnownDevices(by_mac=by_mac, by_hostname=by_hostname)
|
router_cli/display.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Display utilities for terminal output."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
from .config import KnownDevices
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ANSI color codes
|
|
12
|
+
_COLORS = {
|
|
13
|
+
"green": "\033[32m",
|
|
14
|
+
"red": "\033[31m",
|
|
15
|
+
"yellow": "\033[33m",
|
|
16
|
+
"reset": "\033[0m",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def colorize(text: str, color: str) -> str:
|
|
21
|
+
"""Apply ANSI color to text if stdout is a TTY."""
|
|
22
|
+
if not sys.stdout.isatty():
|
|
23
|
+
return text
|
|
24
|
+
return f"{_COLORS.get(color, '')}{text}{_COLORS['reset']}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Spinner frames - braille pattern for smooth animation
|
|
28
|
+
_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
29
|
+
_SPINNER_INTERVAL = 0.08 # seconds between frames
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def spinner(message: str = "Loading..."):
|
|
34
|
+
"""Display an animated spinner while waiting for an operation.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
with spinner("Fetching status..."):
|
|
38
|
+
result = client.get_status()
|
|
39
|
+
|
|
40
|
+
Only displays spinner if stdout is a TTY.
|
|
41
|
+
"""
|
|
42
|
+
if not sys.stdout.isatty():
|
|
43
|
+
# Not a TTY, just run without spinner
|
|
44
|
+
yield
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
stop_event = threading.Event()
|
|
48
|
+
spinner_thread = None
|
|
49
|
+
|
|
50
|
+
def animate():
|
|
51
|
+
frame_idx = 0
|
|
52
|
+
# Hide cursor
|
|
53
|
+
sys.stdout.write("\033[?25l")
|
|
54
|
+
sys.stdout.flush()
|
|
55
|
+
|
|
56
|
+
while not stop_event.is_set():
|
|
57
|
+
frame = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
|
|
58
|
+
# Write spinner frame and message, then return cursor to start
|
|
59
|
+
sys.stdout.write(f"\r{frame} {message}")
|
|
60
|
+
sys.stdout.flush()
|
|
61
|
+
frame_idx += 1
|
|
62
|
+
stop_event.wait(_SPINNER_INTERVAL)
|
|
63
|
+
|
|
64
|
+
# Clear the spinner line
|
|
65
|
+
sys.stdout.write("\r" + " " * (len(message) + 3) + "\r")
|
|
66
|
+
# Show cursor
|
|
67
|
+
sys.stdout.write("\033[?25h")
|
|
68
|
+
sys.stdout.flush()
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
spinner_thread = threading.Thread(target=animate, daemon=True)
|
|
72
|
+
spinner_thread.start()
|
|
73
|
+
yield
|
|
74
|
+
finally:
|
|
75
|
+
stop_event.set()
|
|
76
|
+
if spinner_thread:
|
|
77
|
+
spinner_thread.join(timeout=0.5)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def format_bytes(num_bytes: int) -> str:
|
|
81
|
+
"""Format bytes as human-readable string."""
|
|
82
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
83
|
+
if abs(num_bytes) < 1024:
|
|
84
|
+
return f"{num_bytes:.1f} {unit}"
|
|
85
|
+
num_bytes /= 1024
|
|
86
|
+
return f"{num_bytes:.1f} PB"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_expires(expires_in: str) -> str:
|
|
90
|
+
"""Convert verbose expires string to compact HH:MM:SS format.
|
|
91
|
+
|
|
92
|
+
Input: "22 hours, 27 minutes, 15 seconds"
|
|
93
|
+
Output: "22:27:15"
|
|
94
|
+
"""
|
|
95
|
+
hours = minutes = seconds = 0
|
|
96
|
+
|
|
97
|
+
h_match = re.search(r"(\d+)\s*hour", expires_in)
|
|
98
|
+
m_match = re.search(r"(\d+)\s*minute", expires_in)
|
|
99
|
+
s_match = re.search(r"(\d+)\s*second", expires_in)
|
|
100
|
+
|
|
101
|
+
if h_match:
|
|
102
|
+
hours = int(h_match.group(1))
|
|
103
|
+
if m_match:
|
|
104
|
+
minutes = int(m_match.group(1))
|
|
105
|
+
if s_match:
|
|
106
|
+
seconds = int(s_match.group(1))
|
|
107
|
+
|
|
108
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_device_display(
|
|
112
|
+
mac: str, hostname: str, known_devices: KnownDevices | None
|
|
113
|
+
) -> tuple[str, bool]:
|
|
114
|
+
"""Get display name for a device and whether it's known.
|
|
115
|
+
|
|
116
|
+
Returns (display_name, is_known) tuple.
|
|
117
|
+
If known, display_name is 'Alias (hostname)'.
|
|
118
|
+
|
|
119
|
+
Supports lookup by both MAC address and hostname (for devices with
|
|
120
|
+
random MAC addresses like some Android phones).
|
|
121
|
+
"""
|
|
122
|
+
if known_devices is None:
|
|
123
|
+
return hostname or mac, False
|
|
124
|
+
|
|
125
|
+
alias = known_devices.get_alias(mac, hostname)
|
|
126
|
+
if alias:
|
|
127
|
+
if hostname and hostname != alias:
|
|
128
|
+
return f"{alias} ({hostname})", True
|
|
129
|
+
return alias, True
|
|
130
|
+
return hostname or mac, False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def format_table(
|
|
134
|
+
headers: list[str],
|
|
135
|
+
rows: list[list[str]],
|
|
136
|
+
row_colors: list[str | None] | None = None,
|
|
137
|
+
padding: int = 2,
|
|
138
|
+
) -> str:
|
|
139
|
+
"""Format data as an aligned table with optional row coloring.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
headers: Column header labels
|
|
143
|
+
rows: List of rows, each row is a list of cell values
|
|
144
|
+
row_colors: Optional list of color names (one per row), None for no color
|
|
145
|
+
padding: Extra padding to add to column widths
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Formatted table as a string
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
>>> format_table(
|
|
152
|
+
... ["Name", "Value"],
|
|
153
|
+
... [["foo", "123"], ["bar", "456"]],
|
|
154
|
+
... row_colors=["green", "red"]
|
|
155
|
+
... )
|
|
156
|
+
"""
|
|
157
|
+
if not rows:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
# Calculate column widths based on headers and data
|
|
161
|
+
widths = [len(h) for h in headers]
|
|
162
|
+
for row in rows:
|
|
163
|
+
for i, val in enumerate(row):
|
|
164
|
+
if i < len(widths):
|
|
165
|
+
widths[i] = max(widths[i], len(str(val)))
|
|
166
|
+
|
|
167
|
+
# Add padding
|
|
168
|
+
widths = [w + padding for w in widths]
|
|
169
|
+
|
|
170
|
+
# Build header line
|
|
171
|
+
header_line = "".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
172
|
+
|
|
173
|
+
# Build separator line
|
|
174
|
+
sep_line = "".join(("-" * (w - padding)).ljust(w) for w in widths)
|
|
175
|
+
|
|
176
|
+
# Build data lines
|
|
177
|
+
lines = [header_line, sep_line]
|
|
178
|
+
|
|
179
|
+
for row_idx, row in enumerate(rows):
|
|
180
|
+
line = "".join(
|
|
181
|
+
str(val).ljust(widths[i]) if i < len(widths) else str(val)
|
|
182
|
+
for i, val in enumerate(row)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Apply color if specified
|
|
186
|
+
if row_colors and row_idx < len(row_colors) and row_colors[row_idx]:
|
|
187
|
+
line = colorize(line, row_colors[row_idx])
|
|
188
|
+
|
|
189
|
+
lines.append(line)
|
|
190
|
+
|
|
191
|
+
return "\n".join(lines)
|