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
eeroctl/errors.py ADDED
@@ -0,0 +1,156 @@
1
+ """Centralized error handling for the Eero CLI.
2
+
3
+ This module provides utilities for translating exceptions to user-friendly
4
+ error messages and appropriate exit codes.
5
+ """
6
+
7
+ from typing import Optional, TypeVar
8
+
9
+ from eero.exceptions import (
10
+ EeroAPIException,
11
+ EeroAuthenticationException,
12
+ EeroException,
13
+ EeroFeatureUnavailableException,
14
+ EeroNotFoundException,
15
+ EeroPremiumRequiredException,
16
+ EeroRateLimitException,
17
+ EeroTimeoutException,
18
+ EeroValidationException,
19
+ )
20
+ from rich.console import Console
21
+
22
+ from .exit_codes import ExitCode
23
+
24
+ T = TypeVar("T")
25
+
26
+
27
+ def handle_cli_error(
28
+ e: Exception,
29
+ console: Console,
30
+ renderer: Optional[object] = None,
31
+ context: str = "",
32
+ ) -> int:
33
+ """Handle an exception and return the appropriate exit code.
34
+
35
+ This function translates exceptions into user-friendly error messages
36
+ and determines the correct exit code.
37
+
38
+ Args:
39
+ e: The exception to handle
40
+ console: Rich console for output
41
+ renderer: Optional OutputRenderer for JSON output
42
+ context: Optional context string for better error messages
43
+
44
+ Returns:
45
+ The exit code to use
46
+ """
47
+ prefix = f"{context}: " if context else ""
48
+
49
+ if isinstance(e, EeroAuthenticationException):
50
+ console.print(f"[red]{prefix}Authentication required. Run 'eero auth login' first.[/red]")
51
+ return ExitCode.AUTH_REQUIRED
52
+
53
+ elif isinstance(e, EeroNotFoundException):
54
+ console.print(f"[red]{prefix}{e.resource_type} '{e.resource_id}' not found[/red]")
55
+ return ExitCode.NOT_FOUND
56
+
57
+ elif isinstance(e, EeroPremiumRequiredException):
58
+ console.print(f"[yellow]{prefix}{e.feature} requires Eero Plus subscription[/yellow]")
59
+ return ExitCode.PREMIUM_REQUIRED
60
+
61
+ elif isinstance(e, EeroFeatureUnavailableException):
62
+ console.print(f"[yellow]{prefix}{e.feature} is {e.reason}[/yellow]")
63
+ return ExitCode.FEATURE_UNAVAILABLE
64
+
65
+ elif isinstance(e, EeroRateLimitException):
66
+ console.print(f"[yellow]{prefix}Rate limited. Please wait and try again.[/yellow]")
67
+ return ExitCode.TIMEOUT
68
+
69
+ elif isinstance(e, EeroTimeoutException):
70
+ console.print(f"[red]{prefix}Request timed out. Check your connection and try again.[/red]")
71
+ return ExitCode.TIMEOUT
72
+
73
+ elif isinstance(e, EeroValidationException):
74
+ console.print(f"[red]{prefix}Invalid input for '{e.field}': {e.message}[/red]")
75
+ return ExitCode.USAGE_ERROR
76
+
77
+ elif isinstance(e, EeroAPIException):
78
+ # Map HTTP status codes to exit codes
79
+ if e.status_code == 401:
80
+ console.print(
81
+ f"[red]{prefix}Session expired. Run 'eero auth login' to re-authenticate.[/red]"
82
+ )
83
+ return ExitCode.AUTH_REQUIRED
84
+ elif e.status_code == 403:
85
+ console.print(f"[red]{prefix}Permission denied: {e.message}[/red]")
86
+ return ExitCode.FORBIDDEN
87
+ elif e.status_code == 404:
88
+ console.print(f"[red]{prefix}Resource not found: {e.message}[/red]")
89
+ return ExitCode.NOT_FOUND
90
+ elif e.status_code == 409:
91
+ console.print(f"[yellow]{prefix}Conflict: {e.message}[/yellow]")
92
+ return ExitCode.CONFLICT
93
+ elif e.status_code == 429:
94
+ console.print(f"[yellow]{prefix}Rate limited. Please wait and try again.[/yellow]")
95
+ return ExitCode.TIMEOUT
96
+ else:
97
+ console.print(f"[red]{prefix}API error ({e.status_code}): {e.message}[/red]")
98
+ return ExitCode.GENERIC_ERROR
99
+
100
+ elif isinstance(e, EeroException):
101
+ # Generic Eero exception
102
+ console.print(f"[red]{prefix}{e.message}[/red]")
103
+ return ExitCode.GENERIC_ERROR
104
+
105
+ else:
106
+ # Unknown exception
107
+ console.print(f"[red]{prefix}Unexpected error: {e}[/red]")
108
+ return ExitCode.GENERIC_ERROR
109
+
110
+
111
+ def is_premium_error(e: Exception) -> bool:
112
+ """Check if an exception indicates a premium feature requirement.
113
+
114
+ Args:
115
+ e: Exception to check
116
+
117
+ Returns:
118
+ True if this is a premium-required error
119
+ """
120
+ if isinstance(e, EeroPremiumRequiredException):
121
+ return True
122
+ # Fallback string matching for generic exceptions
123
+ error_str = str(e).lower()
124
+ return "premium" in error_str or "plus" in error_str or "subscription" in error_str
125
+
126
+
127
+ def is_feature_unavailable_error(e: Exception, feature_keyword: str) -> bool:
128
+ """Check if an exception indicates a feature is unavailable.
129
+
130
+ Args:
131
+ e: Exception to check
132
+ feature_keyword: Keyword to look for in error message
133
+
134
+ Returns:
135
+ True if this is a feature-unavailable error
136
+ """
137
+ if isinstance(e, EeroFeatureUnavailableException):
138
+ return True
139
+ # Fallback string matching for generic exceptions
140
+ return feature_keyword.lower() in str(e).lower()
141
+
142
+
143
+ def is_not_found_error(e: Exception) -> bool:
144
+ """Check if an exception indicates a resource was not found.
145
+
146
+ Args:
147
+ e: Exception to check
148
+
149
+ Returns:
150
+ True if this is a not-found error
151
+ """
152
+ if isinstance(e, EeroNotFoundException):
153
+ return True
154
+ if isinstance(e, EeroAPIException) and e.status_code == 404:
155
+ return True
156
+ return "not found" in str(e).lower()
eeroctl/exit_codes.py ADDED
@@ -0,0 +1,68 @@
1
+ """Exit codes for the Eero CLI.
2
+
3
+ Standard exit codes for consistent behavior across all commands.
4
+ """
5
+
6
+ from enum import IntEnum
7
+
8
+
9
+ class ExitCode(IntEnum):
10
+ """Standard exit codes for the CLI.
11
+
12
+ Exit codes must be consistent across all commands for scripting.
13
+ """
14
+
15
+ SUCCESS = 0
16
+ """Command completed successfully."""
17
+
18
+ GENERIC_ERROR = 1
19
+ """Generic/unexpected error."""
20
+
21
+ USAGE_ERROR = 2
22
+ """Invalid arguments or command usage."""
23
+
24
+ AUTH_REQUIRED = 3
25
+ """Authentication required or session expired."""
26
+
27
+ FORBIDDEN = 4
28
+ """Permission denied/forbidden operation."""
29
+
30
+ NOT_FOUND = 5
31
+ """Resource not found (ID, name, MAC address)."""
32
+
33
+ CONFLICT = 6
34
+ """Conflict or invalid state (e.g., already enabled)."""
35
+
36
+ TIMEOUT = 7
37
+ """Request or operation timed out."""
38
+
39
+ SAFETY_RAIL = 8
40
+ """Safety rail triggered - needs --force or confirmation."""
41
+
42
+ # Reserved: 9
43
+
44
+ PARTIAL_SUCCESS = 10
45
+ """Multi-target operation had partial success."""
46
+
47
+ PREMIUM_REQUIRED = 11
48
+ """Feature requires Eero Plus subscription."""
49
+
50
+ FEATURE_UNAVAILABLE = 12
51
+ """Feature is not available on this device or network."""
52
+
53
+
54
+ # Exit code descriptions for help text
55
+ EXIT_CODE_DESCRIPTIONS = {
56
+ ExitCode.SUCCESS: "Operation completed successfully",
57
+ ExitCode.GENERIC_ERROR: "An unexpected error occurred",
58
+ ExitCode.USAGE_ERROR: "Invalid command usage or arguments",
59
+ ExitCode.AUTH_REQUIRED: "Authentication required or session expired",
60
+ ExitCode.FORBIDDEN: "Permission denied for this operation",
61
+ ExitCode.NOT_FOUND: "Requested resource not found",
62
+ ExitCode.CONFLICT: "Operation conflicts with current state",
63
+ ExitCode.TIMEOUT: "Operation timed out",
64
+ ExitCode.SAFETY_RAIL: "Safety confirmation required (use --force)",
65
+ ExitCode.PARTIAL_SUCCESS: "Operation partially completed",
66
+ ExitCode.PREMIUM_REQUIRED: "Feature requires Eero Plus subscription",
67
+ ExitCode.FEATURE_UNAVAILABLE: "Feature not available on this device",
68
+ }
@@ -0,0 +1,90 @@
1
+ """Formatting utilities for the Eero CLI.
2
+
3
+ This module provides reusable formatting functions for displaying
4
+ Eero network data using Rich panels and tables.
5
+
6
+ This __init__.py re-exports all public functions from submodules
7
+ for backward compatibility with existing imports.
8
+ """
9
+
10
+ # Base utilities
11
+ from .base import (
12
+ DetailLevel,
13
+ build_panel,
14
+ console,
15
+ field,
16
+ field_bool,
17
+ field_status,
18
+ format_bool,
19
+ format_datetime,
20
+ format_device_status,
21
+ format_eero_status,
22
+ format_enabled,
23
+ format_network_status,
24
+ get_network_status_value,
25
+ )
26
+
27
+ # Device formatting
28
+ from .device import (
29
+ create_devices_table,
30
+ print_device_details,
31
+ )
32
+
33
+ # Eero device formatting
34
+ from .eero import (
35
+ create_eeros_table,
36
+ print_eero_details,
37
+ )
38
+
39
+ # Miscellaneous formatting
40
+ from .misc import (
41
+ create_blacklist_table,
42
+ print_speedtest_results,
43
+ )
44
+
45
+ # Network formatting
46
+ from .network import (
47
+ create_network_table,
48
+ print_network_details,
49
+ )
50
+
51
+ # Profile formatting
52
+ from .profile import (
53
+ create_profile_devices_table,
54
+ create_profiles_table,
55
+ print_profile_details,
56
+ )
57
+
58
+ # Re-export all public names
59
+ __all__ = [
60
+ # Base
61
+ "console",
62
+ "DetailLevel",
63
+ "get_network_status_value",
64
+ "format_network_status",
65
+ "format_device_status",
66
+ "format_eero_status",
67
+ "format_bool",
68
+ "format_enabled",
69
+ "build_panel",
70
+ "field",
71
+ "field_bool",
72
+ "field_status",
73
+ "format_datetime",
74
+ # Network
75
+ "create_network_table",
76
+ "print_network_details",
77
+ # Eero
78
+ "create_eeros_table",
79
+ "print_eero_details",
80
+ # Device
81
+ "create_devices_table",
82
+ "print_device_details",
83
+ # Profile
84
+ "create_profiles_table",
85
+ "create_profile_devices_table",
86
+ "print_profile_details",
87
+ # Misc
88
+ "print_speedtest_results",
89
+ "create_blacklist_table",
90
+ ]
@@ -0,0 +1,181 @@
1
+ """Base formatting utilities for the Eero CLI.
2
+
3
+ This module provides shared formatting functions, constants, and helpers
4
+ used across all formatting modules.
5
+ """
6
+
7
+ from typing import Any, List, Literal, Optional
8
+
9
+ from eero.const import EeroDeviceStatus
10
+ from eero.models.network import Network
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+
14
+ # Create console for rich output
15
+ console = Console()
16
+
17
+ # Type alias for detail level
18
+ DetailLevel = Literal["brief", "full"]
19
+
20
+
21
+ # ==================== Status Formatting Helpers ====================
22
+
23
+
24
+ def get_network_status_value(network: Network) -> str:
25
+ """Extract the status value from a network, handling both enum and string types.
26
+
27
+ Args:
28
+ network: Network model instance
29
+
30
+ Returns:
31
+ Status string value (e.g., "online", "offline")
32
+ """
33
+ if not network.status:
34
+ return "unknown"
35
+ # Handle both enum (has .value) and string types
36
+ if hasattr(network.status, "value"):
37
+ return str(network.status.value)
38
+ return str(network.status)
39
+
40
+
41
+ def format_network_status(status_value: str) -> tuple[str, str]:
42
+ """Format network status into display text and style.
43
+
44
+ Args:
45
+ status_value: Clean status string (e.g., "online", "offline", "updating")
46
+
47
+ Returns:
48
+ Tuple of (display_text, style)
49
+ """
50
+ status_lower = status_value.lower()
51
+ if status_lower in ("online", "connected"):
52
+ return "online", "green"
53
+ elif status_lower == "offline":
54
+ return "offline", "red"
55
+ elif status_lower == "updating":
56
+ return "updating", "yellow"
57
+ return status_value or "unknown", "dim"
58
+
59
+
60
+ def format_device_status(status: EeroDeviceStatus) -> tuple[str, str]:
61
+ """Format device status into display text and style.
62
+
63
+ Args:
64
+ status: Device status enum
65
+
66
+ Returns:
67
+ Tuple of (display_text, style)
68
+ """
69
+ if status == EeroDeviceStatus.CONNECTED:
70
+ return "connected", "green"
71
+ elif status == EeroDeviceStatus.BLOCKED:
72
+ return "blocked", "red"
73
+ elif status == EeroDeviceStatus.DISCONNECTED:
74
+ return "disconnected", "yellow"
75
+ return "unknown", "dim"
76
+
77
+
78
+ def format_eero_status(status: str) -> tuple[str, str]:
79
+ """Format eero status into display text and style.
80
+
81
+ Args:
82
+ status: Eero status string
83
+
84
+ Returns:
85
+ Tuple of (display_text, style)
86
+ """
87
+ if status == "green":
88
+ return status, "green"
89
+ return status, "red"
90
+
91
+
92
+ def format_bool(value: bool, true_text: str = "Yes", false_text: str = "No") -> str:
93
+ """Format a boolean value with color coding.
94
+
95
+ Args:
96
+ value: Boolean value
97
+ true_text: Text to display when True
98
+ false_text: Text to display when False
99
+
100
+ Returns:
101
+ Formatted string
102
+ """
103
+ if value:
104
+ return f"[green]{true_text}[/green]"
105
+ return f"[dim]{false_text}[/dim]"
106
+
107
+
108
+ def format_enabled(value: bool) -> str:
109
+ """Format an enabled/disabled value."""
110
+ return format_bool(value, "Enabled", "Disabled")
111
+
112
+
113
+ # ==================== Panel Building Helpers ====================
114
+
115
+
116
+ def build_panel(lines: List[str], title: str, border_style: str = "blue") -> Panel:
117
+ """Build a panel from a list of formatted lines.
118
+
119
+ Args:
120
+ lines: List of formatted text lines
121
+ title: Panel title
122
+ border_style: Rich border style
123
+
124
+ Returns:
125
+ Rich Panel object
126
+ """
127
+ # Filter out None and empty lines
128
+ content = "\n".join(line for line in lines if line)
129
+ return Panel(content, title=title, border_style=border_style)
130
+
131
+
132
+ def field(label: str, value: Any, default: str = "Unknown") -> str:
133
+ """Format a single field line.
134
+
135
+ Args:
136
+ label: Field label
137
+ value: Field value
138
+ default: Default value if value is None/empty
139
+
140
+ Returns:
141
+ Formatted line string
142
+ """
143
+ display_value = value if value is not None else default
144
+ if display_value == "":
145
+ display_value = default
146
+ return f"[bold]{label}:[/bold] {display_value}"
147
+
148
+
149
+ def field_bool(label: str, value: Optional[bool]) -> str:
150
+ """Format a boolean field line."""
151
+ return f"[bold]{label}:[/bold] {format_enabled(bool(value)) if value is not None else '[dim]Unknown[/dim]'}"
152
+
153
+
154
+ def field_status(label: str, text: str, style: str) -> str:
155
+ """Format a status field line with color."""
156
+ return f"[bold]{label}:[/bold] [{style}]{text}[/{style}]"
157
+
158
+
159
+ # ==================== Date/Time Formatting ====================
160
+
161
+
162
+ def format_datetime(dt: Any, include_time: bool = True) -> str:
163
+ """Format a datetime value for display.
164
+
165
+ Args:
166
+ dt: Datetime object or string
167
+ include_time: Whether to include time component
168
+
169
+ Returns:
170
+ Formatted datetime string
171
+ """
172
+ if dt is None:
173
+ return "Unknown"
174
+ if hasattr(dt, "strftime"):
175
+ fmt = "%Y-%m-%d %H:%M:%S" if include_time else "%Y-%m-%d"
176
+ return dt.strftime(fmt)
177
+ # Handle string datetime
178
+ dt_str = str(dt)
179
+ if include_time:
180
+ return dt_str[:19].replace("T", " ")
181
+ return dt_str[:10]