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/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() -> dict:
31
- """Load configuration from TOML file.
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
- Searches for config in:
34
- 1. ./config.toml (current directory)
35
- 2. ~/.config/router/config.toml
36
- 3. /etc/router/config.toml
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
- config = _load_config_file()
39
- if config is not None:
40
- return config.get("router", {})
41
-
42
- raise FileNotFoundError(
43
- "No config.toml found. Create one at ~/.config/router/config.toml with:\n"
44
- "[router]\n"
45
- 'ip = "192.168.1.1"\n'
46
- 'username = "admin"\n'
47
- 'password = "your_password"\n'
48
- "\n"
49
- "[known_devices]\n"
50
- '"AA:BB:CC:DD:EE:FF" = "My Phone"\n'
51
- '"11:22:33:44:55:66" = "Smart TV"'
52
- )
53
-
54
-
55
- def load_known_devices() -> dict[str, str]:
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 dict mapping MAC addresses (uppercase) to aliases.
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
- # Normalize MAC addresses to uppercase for case-insensitive matching
66
- return {mac.upper(): alias for mac, alias in known.items()}
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)