eeroctl 1.7.1__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.
Files changed (45) hide show
  1. eeroctl/__init__.py +19 -0
  2. eeroctl/commands/__init__.py +32 -0
  3. eeroctl/commands/activity.py +237 -0
  4. eeroctl/commands/auth.py +471 -0
  5. eeroctl/commands/completion.py +142 -0
  6. eeroctl/commands/device.py +492 -0
  7. eeroctl/commands/eero/__init__.py +12 -0
  8. eeroctl/commands/eero/base.py +224 -0
  9. eeroctl/commands/eero/led.py +154 -0
  10. eeroctl/commands/eero/nightlight.py +235 -0
  11. eeroctl/commands/eero/updates.py +82 -0
  12. eeroctl/commands/network/__init__.py +18 -0
  13. eeroctl/commands/network/advanced.py +191 -0
  14. eeroctl/commands/network/backup.py +162 -0
  15. eeroctl/commands/network/base.py +331 -0
  16. eeroctl/commands/network/dhcp.py +118 -0
  17. eeroctl/commands/network/dns.py +197 -0
  18. eeroctl/commands/network/forwards.py +115 -0
  19. eeroctl/commands/network/guest.py +162 -0
  20. eeroctl/commands/network/security.py +162 -0
  21. eeroctl/commands/network/speedtest.py +99 -0
  22. eeroctl/commands/network/sqm.py +194 -0
  23. eeroctl/commands/profile.py +671 -0
  24. eeroctl/commands/troubleshoot.py +317 -0
  25. eeroctl/context.py +254 -0
  26. eeroctl/errors.py +156 -0
  27. eeroctl/exit_codes.py +68 -0
  28. eeroctl/formatting/__init__.py +90 -0
  29. eeroctl/formatting/base.py +181 -0
  30. eeroctl/formatting/device.py +430 -0
  31. eeroctl/formatting/eero.py +591 -0
  32. eeroctl/formatting/misc.py +87 -0
  33. eeroctl/formatting/network.py +659 -0
  34. eeroctl/formatting/profile.py +443 -0
  35. eeroctl/main.py +161 -0
  36. eeroctl/options.py +429 -0
  37. eeroctl/output.py +739 -0
  38. eeroctl/safety.py +259 -0
  39. eeroctl/utils.py +181 -0
  40. eeroctl-1.7.1.dist-info/METADATA +115 -0
  41. eeroctl-1.7.1.dist-info/RECORD +45 -0
  42. eeroctl-1.7.1.dist-info/WHEEL +5 -0
  43. eeroctl-1.7.1.dist-info/entry_points.txt +3 -0
  44. eeroctl-1.7.1.dist-info/licenses/LICENSE +21 -0
  45. eeroctl-1.7.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,317 @@
1
+ """Troubleshooting commands for the Eero CLI.
2
+
3
+ Commands:
4
+ - eero troubleshoot connectivity: Check connectivity
5
+ - eero troubleshoot ping: Ping a host
6
+ - eero troubleshoot trace: Traceroute to a host
7
+ - eero troubleshoot doctor: Run diagnostic checks
8
+ """
9
+
10
+ from typing import Optional
11
+
12
+ import click
13
+ from eero import EeroClient
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+
17
+ from ..context import ensure_cli_context
18
+ from ..formatting import get_network_status_value
19
+ from ..options import apply_options, network_option, output_option
20
+ from ..utils import with_client
21
+
22
+
23
+ @click.group(name="troubleshoot")
24
+ @click.pass_context
25
+ def troubleshoot_group(ctx: click.Context) -> None:
26
+ """Troubleshooting and diagnostics.
27
+
28
+ \b
29
+ Commands:
30
+ connectivity - Check network connectivity
31
+ ping - Ping a target host
32
+ trace - Traceroute to target
33
+ doctor - Run diagnostic checks
34
+
35
+ \b
36
+ Examples:
37
+ eero troubleshoot connectivity
38
+ eero troubleshoot ping --target 8.8.8.8
39
+ eero troubleshoot doctor
40
+ """
41
+ ensure_cli_context(ctx)
42
+
43
+
44
+ @troubleshoot_group.command(name="connectivity")
45
+ @output_option
46
+ @network_option
47
+ @click.pass_context
48
+ @with_client
49
+ async def troubleshoot_connectivity(
50
+ ctx: click.Context, client: EeroClient, output: Optional[str], network_id: Optional[str]
51
+ ) -> None:
52
+ """Check network connectivity status."""
53
+ cli_ctx = apply_options(ctx, output=output, network_id=network_id)
54
+ console = cli_ctx.console
55
+
56
+ with cli_ctx.status("Checking connectivity..."):
57
+ network = await client.get_network(cli_ctx.network_id)
58
+ diagnostics = await client.get_diagnostics(cli_ctx.network_id)
59
+
60
+ if cli_ctx.is_structured_output():
61
+ data = {
62
+ "network_status": get_network_status_value(network),
63
+ "public_ip": network.public_ip,
64
+ "isp": network.isp_name,
65
+ "diagnostics": diagnostics,
66
+ }
67
+ cli_ctx.render_structured(data, "eero.troubleshoot.connectivity/v1")
68
+ else:
69
+ status = get_network_status_value(network)
70
+ if "online" in status.lower() or "connected" in status.lower():
71
+ status_display = f"[green]{status}[/green]"
72
+ elif "offline" in status.lower():
73
+ status_display = f"[red]{status}[/red]"
74
+ else:
75
+ status_display = f"[yellow]{status}[/yellow]"
76
+
77
+ content = (
78
+ f"[bold]Status:[/bold] {status_display}\n"
79
+ f"[bold]Public IP:[/bold] {network.public_ip or 'N/A'}\n"
80
+ f"[bold]ISP:[/bold] {network.isp_name or 'N/A'}"
81
+ )
82
+
83
+ # Add health info if available
84
+ health = getattr(network, "health", {})
85
+ if health:
86
+ internet_status = health.get("internet", {}).get("status", "Unknown")
87
+ content += f"\n[bold]Internet Status:[/bold] {internet_status}"
88
+
89
+ console.print(Panel(content, title="Connectivity Status", border_style="blue"))
90
+
91
+
92
+ @troubleshoot_group.command(name="ping")
93
+ @click.option("--target", "-t", required=True, help="Target host or IP")
94
+ @click.option("--from", "from_eero", help="Eero node to ping from (ID or name)")
95
+ @output_option
96
+ @network_option
97
+ @click.pass_context
98
+ @with_client
99
+ async def troubleshoot_ping(
100
+ ctx: click.Context,
101
+ client: EeroClient,
102
+ target: str,
103
+ from_eero: Optional[str],
104
+ output: Optional[str],
105
+ network_id: Optional[str],
106
+ ) -> None:
107
+ """Ping a target host.
108
+
109
+ Note: This is a placeholder - actual ping functionality
110
+ depends on Eero API support.
111
+
112
+ \b
113
+ Options:
114
+ --target, -t Target host or IP (required)
115
+ --from Eero node to ping from
116
+ """
117
+ cli_ctx = apply_options(ctx, output=output, network_id=network_id)
118
+ console = cli_ctx.console
119
+
120
+ console.print(
121
+ "[yellow]Note: Direct ping functionality may not be available in Eero API[/yellow]"
122
+ )
123
+ console.print(f"[dim]Target: {target}[/dim]")
124
+ if from_eero:
125
+ console.print(f"[dim]From: {from_eero}[/dim]")
126
+
127
+ with cli_ctx.status("Running diagnostics..."):
128
+ diagnostics = await client.get_diagnostics(cli_ctx.network_id)
129
+
130
+ if cli_ctx.is_structured_output():
131
+ cli_ctx.render_structured(
132
+ {
133
+ "target": target,
134
+ "from_eero": from_eero,
135
+ "note": "Direct ping not available via API",
136
+ "diagnostics": diagnostics,
137
+ },
138
+ "eero.troubleshoot.ping/v1",
139
+ )
140
+ else:
141
+ console.print(
142
+ Panel(
143
+ f"[bold]Target:[/bold] {target}\n"
144
+ f"[bold]From:[/bold] {from_eero or 'gateway'}\n\n"
145
+ "[dim]Ping results would appear here if API supports it.[/dim]\n"
146
+ "[dim]Use `eero troubleshoot doctor` for full diagnostics.[/dim]",
147
+ title="Ping",
148
+ border_style="blue",
149
+ )
150
+ )
151
+
152
+
153
+ @troubleshoot_group.command(name="trace")
154
+ @click.option("--target", "-t", required=True, help="Target host or IP")
155
+ @click.option("--from", "from_eero", help="Eero node to trace from (ID or name)")
156
+ @output_option
157
+ @network_option
158
+ @click.pass_context
159
+ @with_client
160
+ async def troubleshoot_trace(
161
+ ctx: click.Context,
162
+ client: EeroClient,
163
+ target: str,
164
+ from_eero: Optional[str],
165
+ output: Optional[str],
166
+ network_id: Optional[str],
167
+ ) -> None:
168
+ """Traceroute to a target host.
169
+
170
+ Note: This is a placeholder - actual traceroute functionality
171
+ depends on Eero API support.
172
+
173
+ \b
174
+ Options:
175
+ --target, -t Target host or IP (required)
176
+ --from Eero node to trace from
177
+ """
178
+ cli_ctx = apply_options(ctx, output=output, network_id=network_id)
179
+ console = cli_ctx.console
180
+
181
+ console.print(
182
+ "[yellow]Note: Direct traceroute functionality may not be available in Eero API[/yellow]"
183
+ )
184
+ console.print(f"[dim]Target: {target}[/dim]")
185
+
186
+ with cli_ctx.status("Running diagnostics..."):
187
+ # diagnostics not currently used but may be needed for full trace support
188
+ _ = await client.get_diagnostics(cli_ctx.network_id)
189
+ routing = await client.get_routing(cli_ctx.network_id)
190
+
191
+ if cli_ctx.is_structured_output():
192
+ cli_ctx.render_structured(
193
+ {
194
+ "target": target,
195
+ "from_eero": from_eero,
196
+ "note": "Direct traceroute not available via API",
197
+ "routing": routing,
198
+ },
199
+ "eero.troubleshoot.trace/v1",
200
+ )
201
+ else:
202
+ console.print(
203
+ Panel(
204
+ f"[bold]Target:[/bold] {target}\n"
205
+ f"[bold]From:[/bold] {from_eero or 'gateway'}\n\n"
206
+ "[dim]Traceroute results would appear here if API supports it.[/dim]\n"
207
+ "[dim]Use `eero network routing` for routing information.[/dim]",
208
+ title="Traceroute",
209
+ border_style="blue",
210
+ )
211
+ )
212
+
213
+
214
+ @troubleshoot_group.command(name="doctor")
215
+ @output_option
216
+ @network_option
217
+ @click.pass_context
218
+ @with_client
219
+ async def troubleshoot_doctor(
220
+ ctx: click.Context, client: EeroClient, output: Optional[str], network_id: Optional[str]
221
+ ) -> None:
222
+ """Run diagnostic checks on the network.
223
+
224
+ Performs a comprehensive health check of your Eero network
225
+ including connectivity, mesh health, and configuration.
226
+ """
227
+ cli_ctx = apply_options(ctx, output=output, network_id=network_id)
228
+ console = cli_ctx.console
229
+
230
+ checks = []
231
+
232
+ with cli_ctx.status("Running diagnostics..."):
233
+ # Check network status
234
+ try:
235
+ network = await client.get_network(cli_ctx.network_id)
236
+ status = get_network_status_value(network)
237
+ if "online" in status.lower() or "connected" in status.lower():
238
+ checks.append(("Network Status", "pass", status))
239
+ else:
240
+ checks.append(("Network Status", "fail", status))
241
+ except Exception as e:
242
+ checks.append(("Network Status", "fail", str(e)))
243
+
244
+ # Check eeros
245
+ try:
246
+ eeros = await client.get_eeros(cli_ctx.network_id)
247
+ online_count = sum(1 for e in eeros if e.status == "green")
248
+ total_count = len(eeros)
249
+ if online_count == total_count:
250
+ checks.append(("Mesh Nodes", "pass", f"{online_count}/{total_count} online"))
251
+ elif online_count > 0:
252
+ checks.append(("Mesh Nodes", "warn", f"{online_count}/{total_count} online"))
253
+ else:
254
+ checks.append(("Mesh Nodes", "fail", f"0/{total_count} online"))
255
+ except Exception as e:
256
+ checks.append(("Mesh Nodes", "fail", str(e)))
257
+
258
+ # Check devices
259
+ try:
260
+ devices = await client.get_devices(cli_ctx.network_id)
261
+ connected = sum(1 for d in devices if d.connected)
262
+ checks.append(("Connected Devices", "info", f"{connected} devices"))
263
+ except Exception as e:
264
+ checks.append(("Connected Devices", "warn", str(e)))
265
+
266
+ # Check diagnostics
267
+ try:
268
+ _ = await client.get_diagnostics(cli_ctx.network_id)
269
+ checks.append(("Diagnostics API", "pass", "Available"))
270
+ except Exception:
271
+ checks.append(("Diagnostics API", "warn", "Not available"))
272
+
273
+ # Check premium status
274
+ try:
275
+ is_premium = await client.is_premium(cli_ctx.network_id)
276
+ checks.append(("Eero Plus", "info", "Active" if is_premium else "Not active"))
277
+ except Exception:
278
+ checks.append(("Eero Plus", "info", "Unknown"))
279
+
280
+ if cli_ctx.is_structured_output():
281
+ data = {
282
+ "checks": [
283
+ {"name": name, "status": status, "message": msg} for name, status, msg in checks
284
+ ],
285
+ "overall": "pass" if all(s != "fail" for _, s, _ in checks) else "fail",
286
+ }
287
+ cli_ctx.render_structured(data, "eero.troubleshoot.doctor/v1")
288
+ else:
289
+ table = Table(title="Network Health Check")
290
+ table.add_column("Check", style="cyan")
291
+ table.add_column("Status", justify="center")
292
+ table.add_column("Details")
293
+
294
+ for name, status, message in checks:
295
+ if status == "pass":
296
+ status_display = "[green]✓ PASS[/green]"
297
+ elif status == "fail":
298
+ status_display = "[red]✗ FAIL[/red]"
299
+ elif status == "warn":
300
+ status_display = "[yellow]⚠ WARN[/yellow]"
301
+ else:
302
+ status_display = "[blue]ℹ INFO[/blue]"
303
+
304
+ table.add_row(name, status_display, message)
305
+
306
+ console.print(table)
307
+
308
+ # Overall status
309
+ has_failures = any(s == "fail" for _, s, _ in checks)
310
+ has_warnings = any(s == "warn" for _, s, _ in checks)
311
+
312
+ if has_failures:
313
+ console.print("\n[bold red]⚠ Issues detected. Review the checks above.[/bold red]")
314
+ elif has_warnings:
315
+ console.print("\n[bold yellow]⚠ Some warnings detected.[/bold yellow]")
316
+ else:
317
+ console.print("\n[bold green]✓ All checks passed![/bold green]")
eeroctl/context.py ADDED
@@ -0,0 +1,254 @@
1
+ """CLI context management.
2
+
3
+ This module provides a context object that is passed through Click commands
4
+ to share state like the client, output settings, and global flags.
5
+ """
6
+
7
+ from contextlib import nullcontext
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING, Any, ContextManager, Dict, Optional
10
+
11
+ import click
12
+ from eero import EeroClient
13
+ from rich.console import Console
14
+
15
+ from .output import DetailLevel, OutputContext, OutputFormat, OutputManager, OutputRenderer
16
+
17
+ if TYPE_CHECKING:
18
+ pass
19
+
20
+
21
+ @dataclass
22
+ class EeroCliContext:
23
+ """Context object to pass shared resources to Click commands."""
24
+
25
+ # Core components
26
+ client: Optional[EeroClient] = None
27
+ console: Console = field(default_factory=Console)
28
+ err_console: Console = field(default_factory=lambda: Console(stderr=True))
29
+ output_manager: Optional[OutputManager] = None
30
+
31
+ # Network selection
32
+ network_id: Optional[str] = None
33
+
34
+ # Output settings
35
+ output_format: str = "table" # table, list, json
36
+ detail_level: str = "brief" # brief, full
37
+
38
+ # Safety and interaction flags
39
+ non_interactive: bool = False
40
+ force: bool = False
41
+ dry_run: bool = False
42
+ quiet: bool = False
43
+ no_color: bool = False
44
+
45
+ # Debug/logging flags
46
+ debug: bool = False
47
+ verbose: bool = False
48
+
49
+ # Retry/timeout settings
50
+ timeout: Optional[int] = None
51
+ retries: Optional[int] = None
52
+ retry_backoff: Optional[int] = None
53
+
54
+ # Additional storage for subcommand state
55
+ _extra: Dict[str, Any] = field(default_factory=dict)
56
+
57
+ # Cached renderer instance
58
+ _renderer: Optional[OutputRenderer] = field(default=None, repr=False)
59
+
60
+ def __post_init__(self):
61
+ """Initialize derived components."""
62
+ if self.output_manager is None:
63
+ self.output_manager = OutputManager(self.console)
64
+
65
+ @property
66
+ def renderer(self) -> OutputRenderer:
67
+ """Get the output renderer for this context."""
68
+ if self._renderer is None:
69
+ output_ctx = OutputContext(
70
+ format=(
71
+ OutputFormat(self.output_format)
72
+ if self.output_format in ("table", "list", "json", "yaml", "text")
73
+ else OutputFormat.TABLE
74
+ ),
75
+ detail=(
76
+ DetailLevel(self.detail_level)
77
+ if self.detail_level in ("brief", "full")
78
+ else DetailLevel.BRIEF
79
+ ),
80
+ quiet=self.quiet,
81
+ no_color=self.no_color,
82
+ network_id=self.network_id,
83
+ )
84
+ self._renderer = OutputRenderer(output_ctx)
85
+ return self._renderer
86
+
87
+ def is_json_output(self) -> bool:
88
+ """Check if output format is JSON."""
89
+ return self.output_format == "json"
90
+
91
+ def is_yaml_output(self) -> bool:
92
+ """Check if output format is YAML."""
93
+ return self.output_format == "yaml"
94
+
95
+ def is_text_output(self) -> bool:
96
+ """Check if output format is plain text."""
97
+ return self.output_format == "text"
98
+
99
+ def is_structured_output(self) -> bool:
100
+ """Check if output format is a structured format (JSON, YAML, or text)."""
101
+ return self.output_format in ("json", "yaml", "text")
102
+
103
+ def status(self, message: str) -> ContextManager:
104
+ """Return a status spinner context manager, but only for table output.
105
+
106
+ For parseable outputs (json, yaml, list, text), returns a no-op context
107
+ to avoid polluting the output with status messages.
108
+
109
+ Args:
110
+ message: Status message to display
111
+
112
+ Returns:
113
+ Status context manager or nullcontext
114
+ """
115
+ if self.output_format == "table":
116
+ return self.console.status(message)
117
+ return nullcontext()
118
+
119
+ def render_structured(self, data: Any, schema: str) -> None:
120
+ """Render data in the current structured format (JSON, YAML, or text).
121
+
122
+ Args:
123
+ data: Data to render (dict or list)
124
+ schema: Schema identifier for envelope
125
+ """
126
+ if self.is_json_output():
127
+ self.renderer.render_json(data, schema)
128
+ elif self.is_yaml_output():
129
+ self.renderer.render_yaml(data, schema)
130
+ else:
131
+ self.renderer.render_text(data, schema)
132
+
133
+ def get(self, key: str, default: Any = None) -> Any:
134
+ """Get a value from extra storage."""
135
+ return self._extra.get(key, default)
136
+
137
+ def set(self, key: str, value: Any) -> None:
138
+ """Set a value in extra storage."""
139
+ self._extra[key] = value
140
+
141
+ def __getitem__(self, key: str) -> Any:
142
+ """Get a value from extra storage using [] syntax."""
143
+ return self._extra.get(key)
144
+
145
+ def __setitem__(self, key: str, value: Any) -> None:
146
+ """Set a value in extra storage using [] syntax."""
147
+ self._extra[key] = value
148
+
149
+
150
+ def ensure_cli_context(ctx: click.Context) -> EeroCliContext:
151
+ """Ensure the Click context has an EeroCliContext object.
152
+
153
+ If ctx.obj is None or not an EeroCliContext, creates a new one.
154
+
155
+ Args:
156
+ ctx: Click context
157
+
158
+ Returns:
159
+ The EeroCliContext object
160
+ """
161
+ if ctx.obj is None or not isinstance(ctx.obj, EeroCliContext):
162
+ ctx.obj = EeroCliContext()
163
+ return ctx.obj
164
+
165
+
166
+ def get_cli_context(ctx: click.Context) -> EeroCliContext:
167
+ """Get the EeroCliContext from a Click context.
168
+
169
+ Args:
170
+ ctx: Click context
171
+
172
+ Returns:
173
+ The EeroCliContext object
174
+
175
+ Raises:
176
+ RuntimeError: If no EeroCliContext is found
177
+ """
178
+ if ctx.obj is None:
179
+ # Try to find it in parent contexts
180
+ parent = ctx.parent
181
+ while parent is not None:
182
+ if isinstance(parent.obj, EeroCliContext):
183
+ return parent.obj
184
+ parent = parent.parent
185
+ # Create a default one if not found
186
+ ctx.obj = EeroCliContext()
187
+
188
+ if not isinstance(ctx.obj, EeroCliContext):
189
+ raise RuntimeError(
190
+ f"Expected EeroCliContext but got {type(ctx.obj).__name__}. "
191
+ "Make sure the CLI is properly initialized."
192
+ )
193
+
194
+ return ctx.obj
195
+
196
+
197
+ def create_cli_context(
198
+ debug: bool = False,
199
+ verbose: bool = False,
200
+ output_format: str = "table",
201
+ detail_level: str = "brief",
202
+ network_id: Optional[str] = None,
203
+ non_interactive: bool = False,
204
+ force: bool = False,
205
+ dry_run: bool = False,
206
+ quiet: bool = False,
207
+ no_color: bool = False,
208
+ timeout: Optional[int] = None,
209
+ retries: Optional[int] = None,
210
+ retry_backoff: Optional[int] = None,
211
+ ) -> EeroCliContext:
212
+ """Create a new CLI context with the given settings.
213
+
214
+ Factory function for creating an EeroCliContext with specific options.
215
+ Used primarily by the main CLI entry point.
216
+
217
+ Args:
218
+ debug: Enable debug logging.
219
+ verbose: Enable verbose output.
220
+ output_format: Output format (table, list, json).
221
+ detail_level: Detail level (brief, full).
222
+ network_id: Network ID to use.
223
+ non_interactive: Never prompt for input.
224
+ force: Skip confirmation prompts.
225
+ dry_run: Show what would happen without making changes.
226
+ quiet: Suppress non-essential output.
227
+ no_color: Disable colored output.
228
+ timeout: Request timeout in seconds.
229
+ retries: Number of retries for failed requests.
230
+ retry_backoff: Backoff time between retries in milliseconds.
231
+
232
+ Returns:
233
+ A configured EeroCliContext instance.
234
+ """
235
+ console = Console(force_terminal=not no_color, no_color=no_color, quiet=quiet)
236
+
237
+ return EeroCliContext(
238
+ client=None, # Will be initialized later
239
+ console=console,
240
+ output_manager=OutputManager(console),
241
+ network_id=network_id,
242
+ output_format=output_format,
243
+ detail_level=detail_level,
244
+ non_interactive=non_interactive,
245
+ force=force,
246
+ dry_run=dry_run,
247
+ quiet=quiet,
248
+ no_color=no_color,
249
+ debug=debug,
250
+ verbose=verbose,
251
+ timeout=timeout,
252
+ retries=retries,
253
+ retry_backoff=retry_backoff,
254
+ )