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.
- eeroctl/__init__.py +19 -0
- eeroctl/commands/__init__.py +32 -0
- eeroctl/commands/activity.py +237 -0
- eeroctl/commands/auth.py +471 -0
- eeroctl/commands/completion.py +142 -0
- eeroctl/commands/device.py +492 -0
- eeroctl/commands/eero/__init__.py +12 -0
- eeroctl/commands/eero/base.py +224 -0
- eeroctl/commands/eero/led.py +154 -0
- eeroctl/commands/eero/nightlight.py +235 -0
- eeroctl/commands/eero/updates.py +82 -0
- eeroctl/commands/network/__init__.py +18 -0
- eeroctl/commands/network/advanced.py +191 -0
- eeroctl/commands/network/backup.py +162 -0
- eeroctl/commands/network/base.py +331 -0
- eeroctl/commands/network/dhcp.py +118 -0
- eeroctl/commands/network/dns.py +197 -0
- eeroctl/commands/network/forwards.py +115 -0
- eeroctl/commands/network/guest.py +162 -0
- eeroctl/commands/network/security.py +162 -0
- eeroctl/commands/network/speedtest.py +99 -0
- eeroctl/commands/network/sqm.py +194 -0
- eeroctl/commands/profile.py +671 -0
- eeroctl/commands/troubleshoot.py +317 -0
- eeroctl/context.py +254 -0
- eeroctl/errors.py +156 -0
- eeroctl/exit_codes.py +68 -0
- eeroctl/formatting/__init__.py +90 -0
- eeroctl/formatting/base.py +181 -0
- eeroctl/formatting/device.py +430 -0
- eeroctl/formatting/eero.py +591 -0
- eeroctl/formatting/misc.py +87 -0
- eeroctl/formatting/network.py +659 -0
- eeroctl/formatting/profile.py +443 -0
- eeroctl/main.py +161 -0
- eeroctl/options.py +429 -0
- eeroctl/output.py +739 -0
- eeroctl/safety.py +259 -0
- eeroctl/utils.py +181 -0
- eeroctl-1.7.1.dist-info/METADATA +115 -0
- eeroctl-1.7.1.dist-info/RECORD +45 -0
- eeroctl-1.7.1.dist-info/WHEEL +5 -0
- eeroctl-1.7.1.dist-info/entry_points.txt +3 -0
- eeroctl-1.7.1.dist-info/licenses/LICENSE +21 -0
- eeroctl-1.7.1.dist-info/top_level.txt +1 -0
eeroctl/output.py
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
"""Output rendering layer for the Eero CLI.
|
|
2
|
+
|
|
3
|
+
Provides consistent output formatting across all commands:
|
|
4
|
+
- table: Rich formatted tables (default for read commands)
|
|
5
|
+
- list: One-item-per-line grep-friendly format
|
|
6
|
+
- json: Structured JSON with schema envelope
|
|
7
|
+
- yaml: YAML output format
|
|
8
|
+
- text: Plain text key-value format
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json as json_lib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Dict, List, Optional, Protocol, Union
|
|
16
|
+
|
|
17
|
+
import yaml # type: ignore[import-untyped]
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OutputFormat(str, Enum):
|
|
24
|
+
"""Output format options."""
|
|
25
|
+
|
|
26
|
+
TABLE = "table"
|
|
27
|
+
LIST = "list"
|
|
28
|
+
JSON = "json"
|
|
29
|
+
YAML = "yaml"
|
|
30
|
+
TEXT = "text"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DetailLevel(str, Enum):
|
|
34
|
+
"""Detail level for output."""
|
|
35
|
+
|
|
36
|
+
BRIEF = "brief"
|
|
37
|
+
FULL = "full"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class OutputMeta:
|
|
42
|
+
"""Metadata for JSON output envelope."""
|
|
43
|
+
|
|
44
|
+
timestamp: str = field(
|
|
45
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
46
|
+
)
|
|
47
|
+
network_id: Optional[str] = None
|
|
48
|
+
warnings: List[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class OutputContext:
|
|
53
|
+
"""Context for output rendering."""
|
|
54
|
+
|
|
55
|
+
format: OutputFormat = OutputFormat.TABLE
|
|
56
|
+
detail: DetailLevel = DetailLevel.BRIEF
|
|
57
|
+
quiet: bool = False
|
|
58
|
+
no_color: bool = False
|
|
59
|
+
network_id: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
# Console instance (created lazily)
|
|
62
|
+
_console: Optional[Console] = field(default=None, repr=False)
|
|
63
|
+
_err_console: Optional[Console] = field(default=None, repr=False)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def console(self) -> Console:
|
|
67
|
+
"""Get the main console for stdout."""
|
|
68
|
+
if self._console is None:
|
|
69
|
+
self._console = Console(
|
|
70
|
+
no_color=self.no_color,
|
|
71
|
+
force_terminal=None if not self.no_color else False,
|
|
72
|
+
)
|
|
73
|
+
return self._console
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def err_console(self) -> Console:
|
|
77
|
+
"""Get the error console for stderr."""
|
|
78
|
+
if self._err_console is None:
|
|
79
|
+
self._err_console = Console(
|
|
80
|
+
stderr=True,
|
|
81
|
+
no_color=self.no_color,
|
|
82
|
+
force_terminal=None if not self.no_color else False,
|
|
83
|
+
)
|
|
84
|
+
return self._err_console
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Renderable(Protocol):
|
|
88
|
+
"""Protocol for objects that can be rendered."""
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
91
|
+
"""Convert to dictionary for JSON output."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
def to_table_row(self) -> List[str]:
|
|
95
|
+
"""Convert to table row values."""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
def to_list_line(self) -> str:
|
|
99
|
+
"""Convert to single line for list output."""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class OutputRenderer:
|
|
104
|
+
"""Renders output in various formats."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, ctx: OutputContext):
|
|
107
|
+
self.ctx = ctx
|
|
108
|
+
|
|
109
|
+
def render(
|
|
110
|
+
self,
|
|
111
|
+
data: Union[Dict, List, Any],
|
|
112
|
+
schema: str,
|
|
113
|
+
table_columns: Optional[List[Dict[str, Any]]] = None,
|
|
114
|
+
table_rows: Optional[List[List[str]]] = None,
|
|
115
|
+
list_items: Optional[List[str]] = None,
|
|
116
|
+
meta: Optional[OutputMeta] = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Render data in the current output format.
|
|
119
|
+
|
|
120
|
+
This is the unified entry point that automatically routes to the
|
|
121
|
+
appropriate format renderer based on the context's output format.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
data: The data to render (used for json, yaml, text formats)
|
|
125
|
+
schema: Schema identifier (e.g., "eero.network.list/v1")
|
|
126
|
+
table_columns: Column definitions for table format (optional)
|
|
127
|
+
table_rows: Row data for table format (optional)
|
|
128
|
+
list_items: Items for list format (optional)
|
|
129
|
+
meta: Optional metadata for structured outputs
|
|
130
|
+
"""
|
|
131
|
+
if self.ctx.format == OutputFormat.JSON:
|
|
132
|
+
self.render_json(data, schema, meta)
|
|
133
|
+
elif self.ctx.format == OutputFormat.YAML:
|
|
134
|
+
self.render_yaml(data, schema, meta)
|
|
135
|
+
elif self.ctx.format == OutputFormat.TEXT:
|
|
136
|
+
self.render_text(data, schema, meta)
|
|
137
|
+
elif self.ctx.format == OutputFormat.LIST:
|
|
138
|
+
if list_items is not None:
|
|
139
|
+
self.render_list(list_items)
|
|
140
|
+
elif isinstance(data, list):
|
|
141
|
+
# Auto-generate list items from data
|
|
142
|
+
items = []
|
|
143
|
+
for item in data:
|
|
144
|
+
if isinstance(item, dict):
|
|
145
|
+
name = (
|
|
146
|
+
item.get("name")
|
|
147
|
+
or item.get("display_name")
|
|
148
|
+
or item.get("id", "Unknown")
|
|
149
|
+
)
|
|
150
|
+
items.append(str(name))
|
|
151
|
+
else:
|
|
152
|
+
items.append(str(item))
|
|
153
|
+
self.render_list(items)
|
|
154
|
+
else:
|
|
155
|
+
self.render_list([str(data)])
|
|
156
|
+
else: # TABLE (default)
|
|
157
|
+
if table_columns is not None and table_rows is not None:
|
|
158
|
+
# Extract title from schema
|
|
159
|
+
title = schema.split("/")[0].replace("eero.", "").replace(".", " ").title()
|
|
160
|
+
self.render_table(title, table_columns, table_rows)
|
|
161
|
+
else:
|
|
162
|
+
# Fallback to text for single items
|
|
163
|
+
self.render_text(data, schema, meta)
|
|
164
|
+
|
|
165
|
+
def render_json(
|
|
166
|
+
self,
|
|
167
|
+
data: Union[Dict, List, Any],
|
|
168
|
+
schema: str,
|
|
169
|
+
meta: Optional[OutputMeta] = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Render data as JSON with envelope.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
data: The data to render
|
|
175
|
+
schema: Schema identifier (e.g., "eero.network.show/v1")
|
|
176
|
+
meta: Optional metadata
|
|
177
|
+
"""
|
|
178
|
+
if meta is None:
|
|
179
|
+
meta = OutputMeta(network_id=self.ctx.network_id)
|
|
180
|
+
|
|
181
|
+
envelope = {
|
|
182
|
+
"schema": schema,
|
|
183
|
+
"data": data,
|
|
184
|
+
"meta": {
|
|
185
|
+
"timestamp": meta.timestamp,
|
|
186
|
+
"network_id": meta.network_id,
|
|
187
|
+
"warnings": meta.warnings,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Use standard json for clean output
|
|
192
|
+
output = json_lib.dumps(envelope, indent=2, default=str)
|
|
193
|
+
self.ctx.console.print(output, highlight=False)
|
|
194
|
+
|
|
195
|
+
def render_yaml(
|
|
196
|
+
self,
|
|
197
|
+
data: Union[Dict, List, Any],
|
|
198
|
+
schema: str,
|
|
199
|
+
meta: Optional[OutputMeta] = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Render data as YAML with envelope.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
data: The data to render
|
|
205
|
+
schema: Schema identifier (e.g., "eero.network.show/v1")
|
|
206
|
+
meta: Optional metadata
|
|
207
|
+
"""
|
|
208
|
+
if meta is None:
|
|
209
|
+
meta = OutputMeta(network_id=self.ctx.network_id)
|
|
210
|
+
|
|
211
|
+
envelope = {
|
|
212
|
+
"schema": schema,
|
|
213
|
+
"data": data,
|
|
214
|
+
"meta": {
|
|
215
|
+
"timestamp": meta.timestamp,
|
|
216
|
+
"network_id": meta.network_id,
|
|
217
|
+
"warnings": meta.warnings,
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Use yaml for clean output
|
|
222
|
+
output = yaml.dump(envelope, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
223
|
+
self.ctx.console.print(output, highlight=False)
|
|
224
|
+
|
|
225
|
+
def render_text(
|
|
226
|
+
self,
|
|
227
|
+
data: Union[Dict, List, Any],
|
|
228
|
+
schema: str,
|
|
229
|
+
meta: Optional[OutputMeta] = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Render data as plain text key-value pairs.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
data: The data to render
|
|
235
|
+
schema: Schema identifier (used for title)
|
|
236
|
+
meta: Optional metadata (ignored in text output)
|
|
237
|
+
"""
|
|
238
|
+
if isinstance(data, dict):
|
|
239
|
+
self._render_text_dict(data)
|
|
240
|
+
elif isinstance(data, list):
|
|
241
|
+
for i, item in enumerate(data):
|
|
242
|
+
if i > 0:
|
|
243
|
+
self.ctx.console.print("---", highlight=False)
|
|
244
|
+
if isinstance(item, dict):
|
|
245
|
+
self._render_text_dict(item)
|
|
246
|
+
else:
|
|
247
|
+
self.ctx.console.print(str(item), highlight=False)
|
|
248
|
+
else:
|
|
249
|
+
self.ctx.console.print(str(data), highlight=False)
|
|
250
|
+
|
|
251
|
+
def _render_text_dict(self, data: Dict[str, Any], prefix: str = "") -> None:
|
|
252
|
+
"""Render a dictionary as plain text key-value pairs."""
|
|
253
|
+
for key, value in data.items():
|
|
254
|
+
formatted_key = key.replace("_", " ").title()
|
|
255
|
+
if isinstance(value, dict):
|
|
256
|
+
self.ctx.console.print(f"{prefix}{formatted_key}:", highlight=False)
|
|
257
|
+
self._render_text_dict(value, prefix=prefix + " ")
|
|
258
|
+
elif isinstance(value, list):
|
|
259
|
+
if not value:
|
|
260
|
+
self.ctx.console.print(f"{prefix}{formatted_key}: (none)", highlight=False)
|
|
261
|
+
elif all(isinstance(v, (str, int, float, bool)) for v in value):
|
|
262
|
+
self.ctx.console.print(
|
|
263
|
+
f"{prefix}{formatted_key}: {', '.join(str(v) for v in value)}",
|
|
264
|
+
highlight=False,
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
self.ctx.console.print(f"{prefix}{formatted_key}:", highlight=False)
|
|
268
|
+
for item in value:
|
|
269
|
+
if isinstance(item, dict):
|
|
270
|
+
self._render_text_dict(item, prefix=prefix + " ")
|
|
271
|
+
self.ctx.console.print(f"{prefix} ---", highlight=False)
|
|
272
|
+
else:
|
|
273
|
+
self.ctx.console.print(f"{prefix} - {item}", highlight=False)
|
|
274
|
+
elif value is None:
|
|
275
|
+
self.ctx.console.print(f"{prefix}{formatted_key}: -", highlight=False)
|
|
276
|
+
elif isinstance(value, bool):
|
|
277
|
+
self.ctx.console.print(
|
|
278
|
+
f"{prefix}{formatted_key}: {'yes' if value else 'no'}", highlight=False
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
self.ctx.console.print(f"{prefix}{formatted_key}: {value}", highlight=False)
|
|
282
|
+
|
|
283
|
+
def render_mutation_result(
|
|
284
|
+
self,
|
|
285
|
+
changed: bool,
|
|
286
|
+
target: str,
|
|
287
|
+
action: str,
|
|
288
|
+
job_id: Optional[str] = None,
|
|
289
|
+
message: Optional[str] = None,
|
|
290
|
+
schema: Optional[str] = None,
|
|
291
|
+
) -> None:
|
|
292
|
+
"""Render result of a mutating operation.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
changed: Whether the state was changed
|
|
296
|
+
target: Target of the operation
|
|
297
|
+
action: Action performed
|
|
298
|
+
job_id: Optional job ID for async operations
|
|
299
|
+
message: Optional human-readable message
|
|
300
|
+
schema: Schema for JSON output
|
|
301
|
+
"""
|
|
302
|
+
if self.ctx.format == OutputFormat.JSON:
|
|
303
|
+
result = {
|
|
304
|
+
"changed": changed,
|
|
305
|
+
"target": target,
|
|
306
|
+
"action": action,
|
|
307
|
+
}
|
|
308
|
+
if job_id:
|
|
309
|
+
result["job_id"] = job_id
|
|
310
|
+
|
|
311
|
+
self.render_json(result, schema or f"eero.mutation.{action}/v1")
|
|
312
|
+
else:
|
|
313
|
+
if not self.ctx.quiet:
|
|
314
|
+
if message:
|
|
315
|
+
self.ctx.console.print(message)
|
|
316
|
+
else:
|
|
317
|
+
status = "[green]✓[/green]" if changed else "[yellow]•[/yellow]"
|
|
318
|
+
self.ctx.console.print(f"{status} {action}: {target}")
|
|
319
|
+
|
|
320
|
+
def render_table(
|
|
321
|
+
self,
|
|
322
|
+
title: str,
|
|
323
|
+
columns: List[Dict[str, Any]],
|
|
324
|
+
rows: List[List[str]],
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Render data as a Rich table.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
title: Table title
|
|
330
|
+
columns: Column definitions with 'name' and optional 'style', 'justify'
|
|
331
|
+
rows: Row data as list of lists
|
|
332
|
+
"""
|
|
333
|
+
table = Table(title=title, show_header=True)
|
|
334
|
+
|
|
335
|
+
for col in columns:
|
|
336
|
+
table.add_column(
|
|
337
|
+
col["name"],
|
|
338
|
+
style=col.get("style"),
|
|
339
|
+
justify=col.get("justify", "left"),
|
|
340
|
+
no_wrap=col.get("no_wrap", False),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
for row in rows:
|
|
344
|
+
table.add_row(*row)
|
|
345
|
+
|
|
346
|
+
self.ctx.console.print(table)
|
|
347
|
+
|
|
348
|
+
def render_list(self, items: List[str]) -> None:
|
|
349
|
+
"""Render items as one-per-line list.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
items: List of items to render
|
|
353
|
+
"""
|
|
354
|
+
for item in items:
|
|
355
|
+
self.ctx.console.print(item, highlight=False)
|
|
356
|
+
|
|
357
|
+
def render_panel(
|
|
358
|
+
self,
|
|
359
|
+
content: str,
|
|
360
|
+
title: str,
|
|
361
|
+
border_style: str = "blue",
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Render content in a Rich panel.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
content: Panel content
|
|
367
|
+
title: Panel title
|
|
368
|
+
border_style: Border color/style
|
|
369
|
+
"""
|
|
370
|
+
panel = Panel(content, title=title, border_style=border_style)
|
|
371
|
+
self.ctx.console.print(panel)
|
|
372
|
+
|
|
373
|
+
def render_error(self, message: str, hint: Optional[str] = None) -> None:
|
|
374
|
+
"""Render an error message to stderr.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
message: Error message
|
|
378
|
+
hint: Optional hint for resolution
|
|
379
|
+
"""
|
|
380
|
+
self.ctx.err_console.print(f"[bold red]Error:[/bold red] {message}")
|
|
381
|
+
if hint:
|
|
382
|
+
self.ctx.err_console.print(f"[dim]Hint: {hint}[/dim]")
|
|
383
|
+
|
|
384
|
+
def render_warning(self, message: str) -> None:
|
|
385
|
+
"""Render a warning message to stderr.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
message: Warning message
|
|
389
|
+
"""
|
|
390
|
+
self.ctx.err_console.print(f"[yellow]Warning:[/yellow] {message}")
|
|
391
|
+
|
|
392
|
+
def render_success(self, message: str) -> None:
|
|
393
|
+
"""Render a success message.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
message: Success message
|
|
397
|
+
"""
|
|
398
|
+
if not self.ctx.quiet:
|
|
399
|
+
self.ctx.console.print(f"[bold green]✓[/bold green] {message}")
|
|
400
|
+
|
|
401
|
+
def render_info(self, message: str) -> None:
|
|
402
|
+
"""Render an info message.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
message: Info message
|
|
406
|
+
"""
|
|
407
|
+
if not self.ctx.quiet:
|
|
408
|
+
self.ctx.console.print(f"[blue]ℹ[/blue] {message}")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# Convenience function to get renderer from click context
|
|
412
|
+
def get_renderer(ctx) -> OutputRenderer:
|
|
413
|
+
"""Get OutputRenderer from Click context.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
ctx: Click context
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
OutputRenderer instance
|
|
420
|
+
"""
|
|
421
|
+
output_ctx = getattr(ctx.obj, "output_ctx", None)
|
|
422
|
+
if output_ctx is None:
|
|
423
|
+
output_ctx = OutputContext()
|
|
424
|
+
return OutputRenderer(output_ctx)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class OutputManager:
|
|
428
|
+
"""Simplified output manager for easy format-based rendering."""
|
|
429
|
+
|
|
430
|
+
# Define which columns to show for each resource type (based on schema prefix)
|
|
431
|
+
COLUMN_CONFIGS = {
|
|
432
|
+
"eero.network": ["id", "name", "status", "public_ip", "isp_name"],
|
|
433
|
+
"eero.eero": ["eero_id", "location", "model", "status", "is_gateway", "ip_address"],
|
|
434
|
+
"eero.client": ["id", "display_name", "ip", "mac", "connected", "connection_type"],
|
|
435
|
+
"eero.profile": ["id", "name", "paused", "device_count"],
|
|
436
|
+
"eero.activity": ["name", "category", "bytes", "time"],
|
|
437
|
+
"eero.troubleshoot": [
|
|
438
|
+
"network_name",
|
|
439
|
+
"network_status",
|
|
440
|
+
"internet",
|
|
441
|
+
"mesh",
|
|
442
|
+
"total_eeros",
|
|
443
|
+
"total_clients",
|
|
444
|
+
],
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def __init__(self, console: Console):
|
|
448
|
+
self.console = console
|
|
449
|
+
self._err_console = Console(stderr=True)
|
|
450
|
+
|
|
451
|
+
def render(
|
|
452
|
+
self,
|
|
453
|
+
format: str,
|
|
454
|
+
data: Union[Dict, List],
|
|
455
|
+
schema: str,
|
|
456
|
+
meta: Dict[str, Any],
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Render data in the specified format.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
format: Output format (table, list, json, yaml, text)
|
|
462
|
+
data: Data to render
|
|
463
|
+
schema: Schema identifier for JSON/YAML envelope
|
|
464
|
+
meta: Metadata for JSON/YAML envelope
|
|
465
|
+
"""
|
|
466
|
+
if format == "json":
|
|
467
|
+
self._render_json(data, schema, meta)
|
|
468
|
+
elif format == "yaml":
|
|
469
|
+
self._render_yaml(data, schema, meta)
|
|
470
|
+
elif format == "text":
|
|
471
|
+
self._render_text(data, schema)
|
|
472
|
+
elif format == "list":
|
|
473
|
+
self._render_list(data, schema)
|
|
474
|
+
else:
|
|
475
|
+
self._render_table(data, schema)
|
|
476
|
+
|
|
477
|
+
def _render_json(
|
|
478
|
+
self,
|
|
479
|
+
data: Union[Dict, List],
|
|
480
|
+
schema: str,
|
|
481
|
+
meta: Dict[str, Any],
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Render as JSON with envelope."""
|
|
484
|
+
envelope = {
|
|
485
|
+
"schema": schema,
|
|
486
|
+
"data": data,
|
|
487
|
+
"meta": {
|
|
488
|
+
"timestamp": meta.get(
|
|
489
|
+
"timestamp", datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
490
|
+
),
|
|
491
|
+
"network_id": meta.get("network_id"),
|
|
492
|
+
"warnings": meta.get("warnings", []),
|
|
493
|
+
},
|
|
494
|
+
}
|
|
495
|
+
output = json_lib.dumps(envelope, indent=2, default=str)
|
|
496
|
+
self.console.print(output, highlight=False)
|
|
497
|
+
|
|
498
|
+
def _render_yaml(
|
|
499
|
+
self,
|
|
500
|
+
data: Union[Dict, List],
|
|
501
|
+
schema: str,
|
|
502
|
+
meta: Dict[str, Any],
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Render as YAML with envelope."""
|
|
505
|
+
envelope = {
|
|
506
|
+
"schema": schema,
|
|
507
|
+
"data": data,
|
|
508
|
+
"meta": {
|
|
509
|
+
"timestamp": meta.get(
|
|
510
|
+
"timestamp", datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
511
|
+
),
|
|
512
|
+
"network_id": meta.get("network_id"),
|
|
513
|
+
"warnings": meta.get("warnings", []),
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
output = yaml.dump(envelope, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
517
|
+
self.console.print(output, highlight=False)
|
|
518
|
+
|
|
519
|
+
def _render_text(self, data: Union[Dict, List], schema: str = "") -> None:
|
|
520
|
+
"""Render as plain text key-value pairs."""
|
|
521
|
+
if not data:
|
|
522
|
+
self.console.print("No data to display.", highlight=False)
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
if isinstance(data, dict):
|
|
526
|
+
self._render_text_dict(data)
|
|
527
|
+
elif isinstance(data, list):
|
|
528
|
+
for i, item in enumerate(data):
|
|
529
|
+
if i > 0:
|
|
530
|
+
self.console.print("---", highlight=False)
|
|
531
|
+
if isinstance(item, dict):
|
|
532
|
+
self._render_text_dict(item)
|
|
533
|
+
else:
|
|
534
|
+
self.console.print(str(item), highlight=False)
|
|
535
|
+
|
|
536
|
+
def _render_text_dict(self, data: Dict[str, Any], prefix: str = "") -> None:
|
|
537
|
+
"""Render a dictionary as plain text key-value pairs."""
|
|
538
|
+
for key, value in data.items():
|
|
539
|
+
formatted_key = key.replace("_", " ").title()
|
|
540
|
+
if isinstance(value, dict):
|
|
541
|
+
self.console.print(f"{prefix}{formatted_key}:", highlight=False)
|
|
542
|
+
self._render_text_dict(value, prefix=prefix + " ")
|
|
543
|
+
elif isinstance(value, list):
|
|
544
|
+
if not value:
|
|
545
|
+
self.console.print(f"{prefix}{formatted_key}: (none)", highlight=False)
|
|
546
|
+
elif all(isinstance(v, (str, int, float, bool)) for v in value):
|
|
547
|
+
self.console.print(
|
|
548
|
+
f"{prefix}{formatted_key}: {', '.join(str(v) for v in value)}",
|
|
549
|
+
highlight=False,
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
self.console.print(f"{prefix}{formatted_key}:", highlight=False)
|
|
553
|
+
for item in value:
|
|
554
|
+
if isinstance(item, dict):
|
|
555
|
+
self._render_text_dict(item, prefix=prefix + " ")
|
|
556
|
+
self.console.print(f"{prefix} ---", highlight=False)
|
|
557
|
+
else:
|
|
558
|
+
self.console.print(f"{prefix} - {item}", highlight=False)
|
|
559
|
+
elif value is None:
|
|
560
|
+
self.console.print(f"{prefix}{formatted_key}: -", highlight=False)
|
|
561
|
+
elif isinstance(value, bool):
|
|
562
|
+
self.console.print(
|
|
563
|
+
f"{prefix}{formatted_key}: {'yes' if value else 'no'}", highlight=False
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
self.console.print(f"{prefix}{formatted_key}: {value}", highlight=False)
|
|
567
|
+
|
|
568
|
+
def _get_columns_for_schema(self, schema: str, data_keys: List[str]) -> List[str]:
|
|
569
|
+
"""Get the columns to display based on schema and available keys."""
|
|
570
|
+
# Find matching column config
|
|
571
|
+
for prefix, columns in self.COLUMN_CONFIGS.items():
|
|
572
|
+
if schema.startswith(prefix):
|
|
573
|
+
# Filter to only columns that exist in data
|
|
574
|
+
return [c for c in columns if c in data_keys]
|
|
575
|
+
|
|
576
|
+
# Fallback: pick first 6 simple columns (no nested objects)
|
|
577
|
+
return data_keys[:6]
|
|
578
|
+
|
|
579
|
+
def _format_value(self, value: Any) -> str:
|
|
580
|
+
"""Format a value for table/list display."""
|
|
581
|
+
if value is None:
|
|
582
|
+
return "[dim]-[/dim]"
|
|
583
|
+
if isinstance(value, bool):
|
|
584
|
+
return "[green]✓[/green]" if value else "[dim]✗[/dim]"
|
|
585
|
+
if isinstance(value, (list, dict)):
|
|
586
|
+
if not value:
|
|
587
|
+
return "[dim]-[/dim]"
|
|
588
|
+
if isinstance(value, list):
|
|
589
|
+
return f"[dim]{len(value)} items[/dim]"
|
|
590
|
+
return "[dim]...[/dim]"
|
|
591
|
+
# Handle enum values
|
|
592
|
+
value_str = str(value)
|
|
593
|
+
if "." in value_str and value_str.count(".") == 1:
|
|
594
|
+
# Likely an enum like "EeroNetworkStatus.ONLINE"
|
|
595
|
+
value_str = value_str.split(".")[-1].lower()
|
|
596
|
+
return value_str
|
|
597
|
+
|
|
598
|
+
def _render_table(self, data: Union[Dict, List], schema: str = "") -> None:
|
|
599
|
+
"""Render as Rich table with smart column selection."""
|
|
600
|
+
if not data:
|
|
601
|
+
self.console.print("[yellow]No data to display.[/yellow]")
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
# Handle single dict - render as key-value pairs
|
|
605
|
+
if isinstance(data, dict):
|
|
606
|
+
self._render_single_item(data, schema)
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
if not data:
|
|
610
|
+
self.console.print("[yellow]No data to display.[/yellow]")
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
first_item = data[0]
|
|
614
|
+
data_keys = list(first_item.keys())
|
|
615
|
+
|
|
616
|
+
# Get columns to display
|
|
617
|
+
columns = self._get_columns_for_schema(schema, data_keys)
|
|
618
|
+
|
|
619
|
+
if not columns:
|
|
620
|
+
self.console.print("[yellow]No displayable columns.[/yellow]")
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
# Create table
|
|
624
|
+
table = Table(show_header=True, header_style="bold cyan", box=None)
|
|
625
|
+
|
|
626
|
+
# Add columns
|
|
627
|
+
for col in columns:
|
|
628
|
+
col_name = col.replace("_", " ").title()
|
|
629
|
+
table.add_column(col_name, no_wrap=True)
|
|
630
|
+
|
|
631
|
+
# Add rows
|
|
632
|
+
for item in data:
|
|
633
|
+
row_values = [self._format_value(item.get(col)) for col in columns]
|
|
634
|
+
table.add_row(*row_values)
|
|
635
|
+
|
|
636
|
+
self.console.print(table)
|
|
637
|
+
|
|
638
|
+
def _render_single_item(self, data: Dict, schema: str = "") -> None:
|
|
639
|
+
"""Render a single item as key-value pairs."""
|
|
640
|
+
# Define important keys to show first
|
|
641
|
+
priority_keys = ["id", "name", "display_name", "status", "ip", "public_ip", "isp_name"]
|
|
642
|
+
|
|
643
|
+
# Get keys in order: priority first, then others
|
|
644
|
+
all_keys = list(data.keys())
|
|
645
|
+
ordered_keys = [k for k in priority_keys if k in all_keys]
|
|
646
|
+
ordered_keys += [k for k in all_keys if k not in ordered_keys]
|
|
647
|
+
|
|
648
|
+
# Only show non-null, non-complex values
|
|
649
|
+
for key in ordered_keys:
|
|
650
|
+
value = data.get(key)
|
|
651
|
+
if value is None:
|
|
652
|
+
continue
|
|
653
|
+
if isinstance(value, (dict, list)) and not value:
|
|
654
|
+
continue
|
|
655
|
+
|
|
656
|
+
key_label = key.replace("_", " ").title()
|
|
657
|
+
formatted = self._format_value(value)
|
|
658
|
+
|
|
659
|
+
# Skip complex objects in single view
|
|
660
|
+
if formatted in ["[dim]...[/dim]"]:
|
|
661
|
+
continue
|
|
662
|
+
|
|
663
|
+
self.console.print(f"[bold]{key_label}:[/bold] {formatted}")
|
|
664
|
+
|
|
665
|
+
def _render_list(self, data: Union[Dict, List], schema: str = "") -> None:
|
|
666
|
+
"""Render as one-item-per-line list."""
|
|
667
|
+
if not data:
|
|
668
|
+
self.console.print("[yellow]No data to display.[/yellow]")
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
# Handle single dict
|
|
672
|
+
if isinstance(data, dict):
|
|
673
|
+
name = data.get("name") or data.get("display_name") or data.get("id", "Unknown")
|
|
674
|
+
status = data.get("status", "")
|
|
675
|
+
if status:
|
|
676
|
+
self.console.print(f"{name} ({self._format_value(status)})")
|
|
677
|
+
else:
|
|
678
|
+
self.console.print(f"{name}")
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
# For list, show each item on its own line
|
|
682
|
+
for item in data:
|
|
683
|
+
if isinstance(item, dict):
|
|
684
|
+
name = (
|
|
685
|
+
item.get("name")
|
|
686
|
+
or item.get("display_name")
|
|
687
|
+
or item.get("location")
|
|
688
|
+
or item.get("id", "Unknown")
|
|
689
|
+
)
|
|
690
|
+
status = item.get("status", "")
|
|
691
|
+
ip = item.get("ip") or item.get("public_ip") or item.get("ip_address", "")
|
|
692
|
+
|
|
693
|
+
parts = [str(name)]
|
|
694
|
+
if status:
|
|
695
|
+
parts.append(f"({self._format_value(status)})")
|
|
696
|
+
if ip:
|
|
697
|
+
parts.append(f"[dim]{ip}[/dim]")
|
|
698
|
+
|
|
699
|
+
self.console.print(" ".join(parts))
|
|
700
|
+
else:
|
|
701
|
+
self.console.print(str(item))
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# Standard table definitions for common resources
|
|
705
|
+
NETWORK_TABLE_COLUMNS = [
|
|
706
|
+
{"name": "ID", "style": "dim"},
|
|
707
|
+
{"name": "Name", "style": "cyan"},
|
|
708
|
+
{"name": "Status", "style": "green"},
|
|
709
|
+
{"name": "Public IP", "style": "blue"},
|
|
710
|
+
{"name": "ISP", "style": "magenta"},
|
|
711
|
+
]
|
|
712
|
+
|
|
713
|
+
EERO_TABLE_COLUMNS = [
|
|
714
|
+
{"name": "ID", "style": "dim"},
|
|
715
|
+
{"name": "Name", "style": "cyan"},
|
|
716
|
+
{"name": "Model", "style": "green"},
|
|
717
|
+
{"name": "IP", "style": "blue"},
|
|
718
|
+
{"name": "Status"},
|
|
719
|
+
{"name": "Role"},
|
|
720
|
+
{"name": "Connection", "style": "magenta"},
|
|
721
|
+
]
|
|
722
|
+
|
|
723
|
+
CLIENT_TABLE_COLUMNS = [
|
|
724
|
+
{"name": "ID", "style": "dim"},
|
|
725
|
+
{"name": "Name", "style": "cyan"},
|
|
726
|
+
{"name": "IP", "style": "green"},
|
|
727
|
+
{"name": "MAC", "style": "yellow"},
|
|
728
|
+
{"name": "Status"},
|
|
729
|
+
{"name": "Type"},
|
|
730
|
+
{"name": "Connection"},
|
|
731
|
+
]
|
|
732
|
+
|
|
733
|
+
PROFILE_TABLE_COLUMNS = [
|
|
734
|
+
{"name": "ID", "style": "dim"},
|
|
735
|
+
{"name": "Name", "style": "cyan"},
|
|
736
|
+
{"name": "Devices", "justify": "right"},
|
|
737
|
+
{"name": "Paused"},
|
|
738
|
+
{"name": "Schedule"},
|
|
739
|
+
]
|