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/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
+ ]