router-cli 0.2.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,29 +33,77 @@ 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" # by MAC address\n'
51
- '"android-abc123" = "Pixel Phone" # by hostname (for random MACs)'
52
- )
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
53
107
 
54
108
 
55
109
  def _is_mac_address(identifier: str) -> bool:
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)