acex-cli 4.1.2__tar.gz → 4.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acex-cli
3
- Version: 4.1.2
3
+ Version: 4.2.0
4
4
  Summary: ACE-X CLI - Command-line interface for ACE-X
5
5
  License: AGPL-3.0
6
6
  Keywords: automation,cli,control
@@ -11,8 +11,8 @@ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.13
13
13
  Classifier: Programming Language :: Python :: 3.14
14
- Requires-Dist: acex-client (>=4.1.2,<5.0.0)
15
- Requires-Dist: acex-driver-cisco-ioscli (>=0.0.12,<0.0.13)
14
+ Requires-Dist: acex-client (>=4.2.0,<5.0.0)
15
+ Requires-Dist: acex-driver-cisco-ioscli (>=0.0.17,<0.0.18)
16
16
  Requires-Dist: click (==8.1.7)
17
17
  Requires-Dist: ntc-templates (>=8.1.0,<9.0.0)
18
18
  Requires-Dist: rich (>=13.0.0,<14.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "acex-cli"
3
- version = "4.1.2"
3
+ version = "4.2.0"
4
4
  description = "ACE-X CLI - Command-line interface for ACE-X"
5
5
  authors = ["Johan Lahti <johan.lahti@acebit.se>"]
6
6
  readme = "README.md"
@@ -17,8 +17,8 @@ python = "^3.13"
17
17
  typer = "^0.12.0"
18
18
  rich = "^13.0.0"
19
19
  click = "8.1.7"
20
- acex-client = "^4.1.2" # Changes to real dep. by switch_acex_client_dep for publishing
21
- acex-driver-cisco-ioscli = "^0.0.12"
20
+ acex-client = "^4.2.0" # Changes to real dep. by switch_acex_client_dep for publishing
21
+ acex-driver-cisco-ioscli = "^0.0.17"
22
22
  ntc-templates = "^8.1.0"
23
23
 
24
24
  [tool.poetry.scripts]
@@ -8,7 +8,7 @@ import requests
8
8
  import typer
9
9
 
10
10
  from acex_cli.sdk import get_sdk
11
- from acex_cli.print_utils import print_list_table, print_object
11
+ from acex_cli.print_utils import print_list_table, print_object, print_commands_box
12
12
  from acex_client.models.generated_models import Node
13
13
 
14
14
  from acex_devkit.models.composed_configuration import ComposedConfiguration
@@ -29,6 +29,10 @@ def _request_get(sdk, path: str, params: Optional[dict] = None) -> requests.Resp
29
29
  response.raise_for_status()
30
30
  return response
31
31
 
32
+ def _get_connection(sdk, node_id):
33
+ response = _request_get(sdk, f"/inventory/management_connections/?node_id={node_id}")
34
+ return response.json()
35
+
32
36
 
33
37
  def _get_rendered_config(sdk, node_id: str) -> str:
34
38
  response = _request_get(sdk, f"/inventory/node_instances/{node_id}/config")
@@ -249,7 +253,8 @@ def show_diff_plan(
249
253
  dfg = observed_config
250
254
  d = diff
251
255
  ned = sdk.neds.get_driver_instance(node_instance.asset.ned_id)
252
- commands = ned.jusify_diff_commands(observed_config, d, node_instance.asset)
256
+ # commands = ned.jusify_diff_commands(observed_config, d, node_instance.asset)
257
+ commands = ned.render_patch(d, node_instance)
253
258
 
254
259
  print(commands)
255
260
  else:
@@ -258,171 +263,54 @@ def show_diff_plan(
258
263
 
259
264
 
260
265
 
261
-
262
-
263
-
264
-
265
-
266
-
267
-
268
-
269
-
270
-
271
-
266
+ ####################################################################
272
267
 
273
268
  # acex node config diff {from} {to}
274
269
 
275
270
  ####################################################################
276
271
 
272
+ # NODE CONFIG APPLY COMMANDS
273
+ @config_diff_app.command("apply")
274
+ def show_diff_plan(
275
+ ctx: typer.Context,
276
+ node_id: str,
277
+ ):
278
+
279
+ sdk = get_sdk(ctx.obj.get_active_context())
280
+ differ = sdk.differ
281
+
282
+ # Fetch observed config
283
+ observed_config = _get_latest_observed_config(sdk, node_id, "parsed").get("content")
284
+ observed_config = ComposedConfiguration(**observed_config)
277
285
 
278
286
 
287
+ # Fetch desired config
288
+ node_instance = sdk.node_instances.get(node_id)
289
+ if not node_instance:
290
+ typer.echo("Node instance not found.")
291
+ return
279
292
 
293
+ logical_node = sdk.logical_nodes.get(node_instance.logical_node_id)
294
+ if not logical_node:
295
+ typer.echo("Logical node not found.")
296
+ return
280
297
 
298
+ desired_config = getattr(logical_node, "configuration", None)
299
+ if desired_config is None:
300
+ typer.echo("No desired configuration found.")
301
+ return
281
302
 
303
+ diff = differ.diff(observed_config=observed_config, desired_config=desired_config)
304
+ ned = sdk.neds.get_driver_instance(node_instance.asset.ned_id)
282
305
 
306
+ commands = ned.render_patch(diff, node_instance)
307
+ print_commands_box(commands, node_id)
308
+ proceed = typer.confirm(f"Are you sure you want to apply the config above?")
283
309
 
310
+ if not proceed:
311
+ exit()
284
312
 
313
+ connection = _get_connection(sdk, node_id)
285
314
 
286
-
287
- # @config_diff_app.command("observed")
288
- # def observed_config_diff(
289
- # ctx: typer.Context,
290
- # node_id: str,
291
- # diff_from: Optional[str] = typer.Option(
292
- # None, "--from-hash", help="Diff from this backup hash"
293
- # ),
294
- # diff_to: Optional[str] = typer.Option(
295
- # None, "--to-hash", help="Diff to this backup hash"
296
- # ),
297
- # diff_from_time: Optional[str] = typer.Option(
298
- # None,
299
- # "--from-time",
300
- # help="Diff from latest backup at or before this timestamp (ISO 8601)",
301
- # ),
302
- # diff_to_time: Optional[str] = typer.Option(
303
- # None,
304
- # "--to-time",
305
- # help="Diff to latest backup at or before this timestamp (ISO 8601)",
306
- # ),
307
- # ):
308
- # """Diff observed (backup) configs between two hashes."""
309
- # sdk = get_sdk(ctx.obj.get_active_context())
310
-
311
- # using_hashes = diff_from is not None and diff_to is not None
312
- # using_times = diff_from_time is not None and diff_to_time is not None
313
-
314
- # if using_hashes and using_times:
315
- # typer.echo("Use either --from-hash/--to-hash or --from-time/--to-time, not both.")
316
- # raise typer.Exit(code=2)
317
-
318
- # if not using_hashes and not using_times:
319
- # typer.echo("Provide --from-hash/--to-hash or --from-time/--to-time.")
320
- # raise typer.Exit(code=2)
321
-
322
- # if using_times:
323
- # left = _get_observed_config_before(sdk, node_id, diff_from_time)
324
- # right = _get_observed_config_before(sdk, node_id, diff_to_time)
325
- # left_hash = left.get("hash") if isinstance(left, dict) else None
326
- # right_hash = right.get("hash") if isinstance(right, dict) else None
327
- # from_label = (
328
- # f"{node_id}:observed@{diff_from_time}"
329
- # f"{f'#{left_hash}' if left_hash else ''}"
330
- # )
331
- # to_label = (
332
- # f"{node_id}:observed@{diff_to_time}"
333
- # f"{f'#{right_hash}' if right_hash else ''}"
334
- # )
335
- # else:
336
- # left = _get_observed_config_by_hash(sdk, node_id, diff_from)
337
- # right = _get_observed_config_by_hash(sdk, node_id, diff_to)
338
- # from_label = f"{node_id}:{diff_from}"
339
- # to_label = f"{node_id}:{diff_to}"
340
-
341
- # if not left or not right:
342
- # typer.echo("Observed config not found for one or both inputs.")
343
- # return
344
-
345
- # left_content = left.get("content") if isinstance(left, dict) else None
346
- # right_content = right.get("content") if isinstance(right, dict) else None
347
-
348
- # if left_content is None or right_content is None:
349
- # typer.echo("Observed config content missing for one or both hashes.")
350
- # return
351
-
352
- # left_text = _decode_config_content(left_content).splitlines(keepends=True)
353
- # right_text = _decode_config_content(right_content).splitlines(keepends=True)
354
-
355
- # diff = difflib.unified_diff(
356
- # left_text,
357
- # right_text,
358
- # fromfile=from_label,
359
- # tofile=to_label,
360
- # )
361
- # typer.echo("".join(diff))
362
-
363
-
364
- # @config_diff_app.command("desired-observed")
365
- # def desired_observed_diff(
366
- # ctx: typer.Context,
367
- # node_id: str,
368
- # config_hash: Optional[str] = typer.Option(
369
- # None, "--hash", help="Diff against a specific observed backup hash"
370
- # ),
371
- # point_in_time: Optional[str] = typer.Option(
372
- # None,
373
- # "--time",
374
- # help="Diff against latest observed backup at or before this timestamp (ISO 8601)",
375
- # ),
376
- # ):
377
- # """Diff desired config against observed (backup) config."""
378
- # sdk = get_sdk(ctx.obj.get_active_context())
379
-
380
- # if config_hash and point_in_time:
381
- # typer.echo("Use either --hash or --time, not both.")
382
- # raise typer.Exit(code=2)
383
-
384
- # desired = _get_rendered_config(sdk, node_id)
385
- # if config_hash:
386
- # observed = _get_observed_config_by_hash(sdk, node_id, config_hash)
387
- # observed_label = f"{node_id}:observed@{config_hash}"
388
- # elif point_in_time:
389
- # observed = _get_observed_config_before(sdk, node_id, point_in_time)
390
- # observed_label = f"{node_id}:observed@{point_in_time}"
391
- # else:
392
- # observed = _get_latest_observed_config(sdk, node_id)
393
- # observed_label = f"{node_id}:observed"
394
-
395
- # if not observed:
396
- # typer.echo("No observed config found.")
397
- # return
398
-
399
- # content = observed.get("content") if isinstance(observed, dict) else None
400
- # if content is None:
401
- # typer.echo("Observed config has no content.")
402
- # return
403
-
404
- # observed_text = _decode_config_content(content)
405
- # diff = difflib.unified_diff(
406
- # desired.splitlines(keepends=True),
407
- # observed_text.splitlines(keepends=True),
408
- # fromfile=f"{node_id}:desired",
409
- # tofile=observed_label,
410
- # )
411
- # typer.echo("".join(diff))
412
-
413
-
414
- # @config_app.command("observed-list")
415
- # def observed_config_list(ctx: typer.Context, node_id: str):
416
- # """List observed config backups for a node instance."""
417
- # sdk = get_sdk(ctx.obj.get_active_context())
418
- # backups = _list_observed_config_hashes(sdk, node_id)
419
- # if not backups:
420
- # typer.echo("No observed config backups found.")
421
- # return
422
-
423
- # print_list_table(
424
- # backups,
425
- # columns=["id", "hash", "created_at", "node_instance_id"],
426
- # title=f"Observed config backups for node {node_id}",
427
- # )
315
+ ned.apply_patch(diff, node_instance, connection[0].get("target_ip"))
428
316
 
@@ -0,0 +1,345 @@
1
+ """Formatters for displaying configuration diffs in CLI."""
2
+ import json
3
+ from rich.tree import Tree
4
+ from rich.text import Text
5
+ from rich.table import Table
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+ from pydantic import BaseModel
11
+ from acex_devkit.configdiffer import Diff, ComponentDiffOp
12
+
13
+
14
+ def _as_dict(obj) -> dict:
15
+ """Convert a Pydantic model or dict to a plain dict for iteration."""
16
+ if isinstance(obj, BaseModel):
17
+ return obj.model_dump()
18
+ if isinstance(obj, dict):
19
+ return obj
20
+ return {}
21
+
22
+
23
+ def print_diff_summary(diff):
24
+ """Print a summary of the diff statistics."""
25
+
26
+ if not isinstance(diff, Diff):
27
+ console.print("[red]Invalid diff object[/red]")
28
+ return
29
+
30
+ if diff.is_empty():
31
+ console.print("[green]✓ No changes detected[/green]")
32
+ return
33
+
34
+ summary = diff.summary()
35
+
36
+ table = Table(title="Configuration Diff Summary")
37
+ table.add_column("Operation", style="bold")
38
+ table.add_column("Count", style="cyan", justify="right")
39
+
40
+ if summary.get("added", 0) > 0:
41
+ table.add_row("Added", f"[green]{summary['added']}[/green]")
42
+ if summary.get("removed", 0) > 0:
43
+ table.add_row("Removed", f"[red]{summary['removed']}[/red]")
44
+ if summary.get("changed", 0) > 0:
45
+ table.add_row("Changed", f"[yellow]{summary['changed']}[/yellow]")
46
+
47
+ console.print(table)
48
+
49
+
50
+ def print_diff_tree(diff, max_depth: int = None, show_values: bool = True):
51
+ """
52
+ Print diff as a hierarchical tree view.
53
+
54
+ Args:
55
+ diff: Diff object from configdiffer
56
+ max_depth: Maximum depth to show (None for unlimited)
57
+ show_values: Whether to show before/after values
58
+ """
59
+
60
+ if not isinstance(diff, Diff):
61
+ console.print("[red]Invalid diff object[/red]")
62
+ return
63
+
64
+ if diff.is_empty():
65
+ console.print("[green]✓ No changes detected[/green]")
66
+ return
67
+
68
+ # Create colored title with summary
69
+ summary = diff.summary()
70
+ title = Text("🔀 Configuration Changes ", style="bold")
71
+ title.append("(", style="dim")
72
+
73
+ parts = []
74
+ if summary.get("added", 0) > 0:
75
+ parts.append((f"+{summary['added']}", "green"))
76
+ if summary.get("removed", 0) > 0:
77
+ parts.append((f"-{summary['removed']}", "red"))
78
+ if summary.get("changed", 0) > 0:
79
+ parts.append((f"~{summary['changed']}", "yellow"))
80
+
81
+ for i, (text, color) in enumerate(parts):
82
+ if i > 0:
83
+ title.append(" ", style="dim")
84
+ title.append(text, style=f"bold {color}")
85
+
86
+ title.append(")", style="dim")
87
+
88
+ tree = Tree(title)
89
+
90
+ # Build a hierarchical tree structure based on component paths
91
+ # We'll use a dict to track nodes: path_tuple -> tree_node
92
+ path_nodes = {}
93
+
94
+ # Group changes by top-level section first to get counts
95
+ sections = {}
96
+ for change in diff.get_all_changes():
97
+ section = change.path[0] if change.path else "root"
98
+ if section not in sections:
99
+ sections[section] = []
100
+ sections[section].append(change)
101
+
102
+ # Process each change and build nested structure
103
+ for section_name in sorted(sections.keys()):
104
+ section_changes = sections[section_name]
105
+
106
+ # Create section node
107
+ section_label = Text()
108
+ section_label.append(section_name, style="bold cyan")
109
+ section_label.append(f" ({len(section_changes)} changes)", style="dim")
110
+ section_node = tree.add(section_label)
111
+ path_nodes[(section_name,)] = section_node
112
+
113
+ # Add each change, building nested path structure
114
+ for change in section_changes:
115
+ # Build path incrementally
116
+ path = tuple(change.path)
117
+
118
+ # Find or create parent nodes for nested path
119
+ parent_node = section_node
120
+ for i in range(1, len(path)):
121
+ partial_path = path[:i+1]
122
+
123
+ # Check if this is the final component or an intermediate path
124
+ if i == len(path) - 1:
125
+ # This is the actual component with the change
126
+ if change.op == ComponentDiffOp.ADD:
127
+ style = "green"
128
+ prefix = "+ "
129
+ elif change.op == ComponentDiffOp.REMOVE:
130
+ style = "red"
131
+ prefix = "- "
132
+ else: # CHANGE
133
+ style = "yellow"
134
+ prefix = "~ "
135
+
136
+ # Component label
137
+ comp_label = Text()
138
+ comp_label.append(prefix, style=f"bold {style}")
139
+ comp_label.append(f"{change.component_type}: ", style=f"dim {style}")
140
+ comp_label.append(change.component_name, style=style)
141
+
142
+ comp_node = parent_node.add(comp_label)
143
+
144
+ if show_values:
145
+ # Show attributes for ADD operations
146
+ if change.op == ComponentDiffOp.ADD and change.after_dict:
147
+ for attr_name, attr_value in change.after_dict.items():
148
+ # Skip metadata and type fields
149
+ if attr_name in ['metadata', 'type']:
150
+ continue
151
+ attr_label = Text()
152
+ attr_label.append(" ", style="dim")
153
+ attr_label.append(attr_name, style="cyan")
154
+ attr_label.append(": ", style="dim")
155
+ formatted_val = _format_value(attr_value)
156
+ attr_label.append(formatted_val, style="dim green" if formatted_val == "null" else "green")
157
+ comp_node.add(attr_label)
158
+
159
+ # Show attributes for REMOVE operations
160
+ elif change.op == ComponentDiffOp.REMOVE and change.before_dict:
161
+ for attr_name, attr_value in change.before_dict.items():
162
+ # Skip metadata and type fields
163
+ if attr_name in ['metadata', 'type']:
164
+ continue
165
+ attr_label = Text()
166
+ attr_label.append(" ", style="dim")
167
+ attr_label.append(attr_name, style="cyan")
168
+ attr_label.append(": ", style="dim")
169
+ formatted_val = _format_value(attr_value)
170
+ attr_label.append(formatted_val, style="dim red" if formatted_val == "null" else "red")
171
+ comp_node.add(attr_label)
172
+
173
+ # Show changed attributes for CHANGE operations
174
+ elif change.op == ComponentDiffOp.CHANGE and change.changed_attributes:
175
+ for attr in change.changed_attributes:
176
+ attr_label = Text()
177
+ attr_label.append(" ", style="dim")
178
+ attr_label.append(attr.attribute_name, style="cyan")
179
+ attr_label.append(": ", style="dim")
180
+ before_val = _format_value(attr.before)
181
+ attr_label.append(before_val, style="dim red" if before_val == "null" else "red")
182
+ attr_label.append(" → ", style="dim")
183
+ after_val = _format_value(attr.after)
184
+ attr_label.append(after_val, style="dim green" if after_val == "null" else "green")
185
+ comp_node.add(attr_label)
186
+ else:
187
+ # Intermediate path element - create if doesn't exist
188
+ if partial_path not in path_nodes:
189
+ path_label = Text()
190
+ path_label.append(path[i], style="dim cyan")
191
+ path_node = parent_node.add(path_label)
192
+ path_nodes[partial_path] = path_node
193
+ parent_node = path_nodes[partial_path]
194
+
195
+ console.print(tree)
196
+
197
+
198
+ def print_diff_compact(diff, show_unchanged: bool = False):
199
+ """
200
+ Print diff in a compact, git-like format.
201
+
202
+ Args:
203
+ diff: Diff object from configdiffer
204
+ show_unchanged: Whether to show unchanged paths
205
+ """
206
+
207
+ if not isinstance(diff, Diff):
208
+ console.print("[red]Invalid diff object[/red]")
209
+ return
210
+
211
+ if diff.is_empty():
212
+ console.print("[green]✓ No changes detected[/green]")
213
+ return
214
+
215
+ if diff.added:
216
+ console.print("\n[bold green]✚ Added:[/bold green]")
217
+ for change in diff.added:
218
+ path = change.get_path_str()
219
+ console.print(f" [green]+ {change.component_type_name_name}:[/green] {change.component_name}")
220
+
221
+ if diff.removed:
222
+ console.print("\n[bold red]✖ Removed:[/bold red]")
223
+ for change in diff.removed:
224
+ path = change.get_path_str()
225
+ console.print(f" [red]- {change.component_type_name_name}:[/red] {change.component_name}")
226
+
227
+ if diff.changed:
228
+ console.print("\n[bold yellow]⚡ Modified:[/bold yellow]")
229
+ for change in diff.changed:
230
+ console.print(f" [yellow]~ {change.component_type_name_name}:[/yellow] {change.component_name}")
231
+ if change.changed_attributes:
232
+ for attr in change.changed_attributes:
233
+ before_str = _format_value(attr.before, max_length=40)
234
+ after_str = _format_value(attr.after, max_length=40)
235
+ console.print(f" {attr.attribute_name}:")
236
+ console.print(f" [red]- {before_str}[/red]")
237
+ console.print(f" [green]+ {after_str}[/green]")
238
+
239
+
240
+ def print_diff_flat(diff):
241
+ """
242
+ Print diff in a flat list format, good for quick scanning.
243
+
244
+ Args:
245
+ diff: Diff object from configdiffer
246
+ """
247
+
248
+ if not isinstance(diff, Diff):
249
+ console.print("[red]Invalid diff object[/red]")
250
+ return
251
+
252
+ if diff.is_empty():
253
+ console.print("[green]✓ No changes detected[/green]")
254
+ return
255
+
256
+ # Create table
257
+ table = Table(title="Configuration Changes", show_lines=True)
258
+ table.add_column("Op", style="bold", width=3)
259
+ table.add_column("Type", style="cyan")
260
+ table.add_column("Component", style="bold")
261
+ table.add_column("Attribute", style="magenta")
262
+ table.add_column("Before", style="red", max_width=30)
263
+ table.add_column("After", style="green", max_width=30)
264
+
265
+ for change in diff.added:
266
+ table.add_row(
267
+ "[green]+[/green]",
268
+ change.component_type_name_name,
269
+ change.component_name,
270
+ "[dim]--[/dim]",
271
+ "",
272
+ "[dim]new component[/dim]"
273
+ )
274
+
275
+ for change in diff.removed:
276
+ table.add_row(
277
+ "[red]-[/red]",
278
+ change.component_type_name_name,
279
+ change.component_name,
280
+ "[dim]--[/dim]",
281
+ "[dim]component deleted[/dim]",
282
+ ""
283
+ )
284
+
285
+ for change in diff.changed:
286
+ if change.changed_attributes:
287
+ for i, attr in enumerate(change.changed_attributes):
288
+ table.add_row(
289
+ "[yellow]~[/yellow]" if i == 0 else "",
290
+ change.component_type_name_name if i == 0 else "",
291
+ change.component_name if i == 0 else "",
292
+ attr.attribute_name,
293
+ _format_value(attr.before, max_length=30),
294
+ _format_value(attr.after, max_length=30)
295
+ )
296
+ else:
297
+ table.add_row(
298
+ "[yellow]~[/yellow]",
299
+ change.component_type_name_name,
300
+ change.component_name,
301
+ "[dim]--[/dim]",
302
+ "[dim]changed[/dim]",
303
+ "[dim]changed[/dim]"
304
+ )
305
+
306
+ console.print(table)
307
+
308
+
309
+ def _format_value(value, max_length: int = 100) -> str:
310
+ """Format a value for display, truncating if necessary."""
311
+
312
+ if value is None:
313
+ return "null"
314
+
315
+ # Check if this is an AttributeValue (Pydantic model or dict with 'value' key)
316
+ if isinstance(value, BaseModel) and hasattr(value, 'value'):
317
+ value = value.value
318
+ if value is None:
319
+ return "null"
320
+ elif isinstance(value, dict) and 'value' in value:
321
+ value = value['value']
322
+ if value is None:
323
+ return "null"
324
+
325
+ if isinstance(value, BaseModel):
326
+ value_str = json.dumps(value.model_dump(), separators=(',', ':'))
327
+ elif isinstance(value, (dict, list)):
328
+ # Convert any nested BaseModel instances before serializing
329
+ def _to_serializable(obj):
330
+ if isinstance(obj, BaseModel):
331
+ return obj.model_dump()
332
+ if isinstance(obj, dict):
333
+ return {k: _to_serializable(v) for k, v in obj.items()}
334
+ if isinstance(obj, list):
335
+ return [_to_serializable(i) for i in obj]
336
+ return obj
337
+ value_str = json.dumps(_to_serializable(value), separators=(',', ':'))
338
+ else:
339
+ value_str = str(value)
340
+
341
+ # Truncate if too long
342
+ if len(value_str) > max_length:
343
+ return value_str[:max_length-3] + "..."
344
+
345
+ return value_str
@@ -10,8 +10,10 @@ app = typer.Typer(
10
10
  name="acex",
11
11
  help="ACE-X - Automation & Control Ecosystem CLI",
12
12
  add_completion=True,
13
+ pretty_exceptions_enable=False
13
14
  )
14
15
 
16
+
15
17
  # Dynamiskt importera och lägg till alla Typer-appar från commands-mappen
16
18
  commands_path = pathlib.Path(__file__).parent / "commands"
17
19
  for module_info in pkgutil.iter_modules([str(commands_path)]):
@@ -2,6 +2,8 @@ from rich.table import Table
2
2
  from rich.console import Console
3
3
  from rich.tree import Tree
4
4
  from rich.text import Text
5
+ from rich.panel import Panel
6
+ from rich.syntax import Syntax
5
7
 
6
8
  console = Console()
7
9
 
@@ -45,3 +47,16 @@ def print_list_table(data, columns=None, title=None, pydantic_class=None):
45
47
  table.add_row(*(str(row.get(col, "")) for col in columns))
46
48
  console.print(table)
47
49
 
50
+
51
+ def print_commands_box(commands: str, node_id: str):
52
+ """Print CLI commands in a styled panel box."""
53
+ syntax = Syntax(commands, "bash", theme="monokai", line_numbers=True, word_wrap=True)
54
+ console.print(
55
+ Panel(
56
+ syntax,
57
+ title=f"[bold cyan]node_id: {node_id}[/bold cyan]",
58
+ subtitle="[dim]commands to apply[/dim]",
59
+ border_style="cyan",
60
+ padding=(1, 2),
61
+ )
62
+ )
@@ -1,261 +0,0 @@
1
- """Formatters for displaying configuration diffs in CLI."""
2
-
3
- from rich.tree import Tree
4
- from rich.text import Text
5
- from rich.table import Table
6
- from rich.console import Console
7
-
8
- console = Console()
9
-
10
-
11
- def print_diff_summary(diff):
12
- """Print a summary of the diff statistics."""
13
- from acex_devkit.configdiffer.configdiffer import Diff
14
-
15
- if not isinstance(diff, Diff):
16
- console.print("[red]Invalid diff object[/red]")
17
- return
18
-
19
- if diff.is_empty():
20
- console.print("[green]✓ No changes detected[/green]")
21
- return
22
-
23
- summary = diff.summary()
24
-
25
- table = Table(title="Configuration Diff Summary")
26
- table.add_column("Operation", style="bold")
27
- table.add_column("Count", style="cyan", justify="right")
28
-
29
- if summary.get("add", 0) > 0:
30
- table.add_row("Added", f"[green]{summary['add']}[/green]")
31
- if summary.get("remove", 0) > 0:
32
- table.add_row("Removed", f"[red]{summary['remove']}[/red]")
33
- if summary.get("change", 0) > 0:
34
- table.add_row("Changed", f"[yellow]{summary['change']}[/yellow]")
35
-
36
- console.print(table)
37
-
38
-
39
- def print_diff_tree(diff, max_depth: int = None, show_values: bool = True):
40
- """
41
- Print diff as a hierarchical tree view.
42
-
43
- Args:
44
- diff: Diff object from configdiffer
45
- max_depth: Maximum depth to show (None for unlimited)
46
- show_values: Whether to show before/after values
47
- """
48
- from acex_devkit.configdiffer.configdiffer import Diff, DiffNode, DiffOp
49
-
50
- if not isinstance(diff, Diff):
51
- console.print("[red]Invalid diff object[/red]")
52
- return
53
-
54
- if diff.is_empty():
55
- console.print("[green]✓ No changes detected[/green]")
56
- return
57
-
58
- # Create colored title with summary
59
- summary = diff.summary()
60
- title = Text("🔀 Configuration Changes ", style="bold")
61
- title.append("(", style="dim")
62
-
63
- parts = []
64
- if summary.get("add", 0) > 0:
65
- parts.append((f"+{summary['add']}", "green"))
66
- if summary.get("remove", 0) > 0:
67
- parts.append((f"-{summary['remove']}", "red"))
68
- if summary.get("change", 0) > 0:
69
- parts.append((f"~{summary['change']}", "yellow"))
70
-
71
- for i, (text, color) in enumerate(parts):
72
- if i > 0:
73
- title.append(" ", style="dim")
74
- title.append(text, style=f"bold {color}")
75
-
76
- title.append(")", style="dim")
77
-
78
- tree = Tree(title)
79
-
80
- def add_node(parent_tree, node: DiffNode, key: str, depth: int = 0):
81
- """Recursively add nodes to the tree."""
82
- if max_depth is not None and depth > max_depth:
83
- return
84
-
85
- # Format the node based on operation
86
- if node.op == DiffOp.ADD:
87
- style = "green"
88
- prefix = "+ "
89
- value_str = f" = {_format_value(node.after)}" if show_values and node.after is not None else ""
90
- elif node.op == DiffOp.REMOVE:
91
- style = "red"
92
- prefix = "- "
93
- value_str = f" = {_format_value(node.before)}" if show_values and node.before is not None else ""
94
- elif node.op == DiffOp.CHANGE:
95
- style = "yellow"
96
- prefix = "~ "
97
- if show_values and node.children is None:
98
- # Leaf node with changed value
99
- value_str = f" {_format_value(node.before)} → {_format_value(node.after)}"
100
- else:
101
- value_str = ""
102
- else:
103
- style = "white"
104
- prefix = " "
105
- value_str = ""
106
-
107
- # Create the tree node
108
- label = Text()
109
- label.append(prefix, style=f"bold {style}")
110
- label.append(key, style=style)
111
- label.append(value_str, style=style)
112
-
113
- branch = parent_tree.add(label)
114
-
115
- # Add children if they exist
116
- if node.children:
117
- for child_key, child_node in sorted(node.children.items()):
118
- add_node(branch, child_node, child_key, depth + 1)
119
-
120
- # Start with root's children
121
- if diff.root.children:
122
- for key, node in sorted(diff.root.children.items()):
123
- add_node(tree, node, key, depth=0)
124
-
125
- console.print(tree)
126
-
127
-
128
- def print_diff_compact(diff, show_unchanged: bool = False):
129
- """
130
- Print diff in a compact, git-like format.
131
-
132
- Args:
133
- diff: Diff object from configdiffer
134
- show_unchanged: Whether to show unchanged paths
135
- """
136
- from acex_devkit.configdiffer.configdiffer import Diff, DiffNode, DiffOp
137
-
138
- if not isinstance(diff, Diff):
139
- console.print("[red]Invalid diff object[/red]")
140
- return
141
-
142
- if diff.is_empty():
143
- console.print("[green]✓ No changes detected[/green]")
144
- return
145
-
146
- changes = []
147
-
148
- def collect_changes(node: DiffNode, path: str = ""):
149
- """Recursively collect all changes with their paths."""
150
- if node.children:
151
- for key, child in sorted(node.children.items()):
152
- new_path = f"{path}.{key}" if path else key
153
- collect_changes(child, new_path)
154
- else:
155
- # Leaf node
156
- changes.append((path, node))
157
-
158
- collect_changes(diff.root)
159
-
160
- # Group by operation type
161
- adds = [(p, n) for p, n in changes if n.op == DiffOp.ADD]
162
- removes = [(p, n) for p, n in changes if n.op == DiffOp.REMOVE]
163
- modifications = [(p, n) for p, n in changes if n.op == DiffOp.CHANGE]
164
-
165
- if adds:
166
- console.print("\n[bold green]✚ Added:[/bold green]")
167
- for path, node in adds:
168
- value_str = _format_value(node.after, max_length=60)
169
- console.print(f" [green]+ {path}[/green] = {value_str}")
170
-
171
- if removes:
172
- console.print("\n[bold red]✖ Removed:[/bold red]")
173
- for path, node in removes:
174
- value_str = _format_value(node.before, max_length=60)
175
- console.print(f" [red]- {path}[/red] = {value_str}")
176
-
177
- if modifications:
178
- console.print("\n[bold yellow]⚡ Modified:[/bold yellow]")
179
- for path, node in modifications:
180
- before_str = _format_value(node.before, max_length=40)
181
- after_str = _format_value(node.after, max_length=40)
182
- console.print(f" [yellow]~ {path}[/yellow]")
183
- console.print(f" [red]- {before_str}[/red]")
184
- console.print(f" [green]+ {after_str}[/green]")
185
-
186
-
187
- def print_diff_flat(diff):
188
- """
189
- Print diff in a flat list format, good for quick scanning.
190
-
191
- Args:
192
- diff: Diff object from configdiffer
193
- """
194
- from acex_devkit.configdiffer.configdiffer import Diff, DiffNode, DiffOp
195
-
196
- if not isinstance(diff, Diff):
197
- console.print("[red]Invalid diff object[/red]")
198
- return
199
-
200
- if diff.is_empty():
201
- console.print("[green]✓ No changes detected[/green]")
202
- return
203
-
204
- changes = []
205
-
206
- def collect_changes(node: DiffNode, path: str = ""):
207
- """Recursively collect all changes with their paths."""
208
- if node.children:
209
- for key, child in sorted(node.children.items()):
210
- new_path = f"{path}.{key}" if path else key
211
- collect_changes(child, new_path)
212
- else:
213
- # Leaf node
214
- changes.append((path, node))
215
-
216
- collect_changes(diff.root)
217
-
218
- # Create table
219
- table = Table(title="Configuration Changes", show_lines=True)
220
- table.add_column("Op", style="bold", width=3)
221
- table.add_column("Path", style="cyan")
222
- table.add_column("Before", style="red", max_width=40)
223
- table.add_column("After", style="green", max_width=40)
224
-
225
- for path, node in changes:
226
- if node.op == DiffOp.ADD:
227
- op_symbol = "[green]+[/green]"
228
- before = ""
229
- after = _format_value(node.after, max_length=40)
230
- elif node.op == DiffOp.REMOVE:
231
- op_symbol = "[red]-[/red]"
232
- before = _format_value(node.before, max_length=40)
233
- after = ""
234
- else: # CHANGE
235
- op_symbol = "[yellow]~[/yellow]"
236
- before = _format_value(node.before, max_length=40)
237
- after = _format_value(node.after, max_length=40)
238
-
239
- table.add_row(op_symbol, path, before, after)
240
-
241
- console.print(table)
242
-
243
-
244
- def _format_value(value, max_length: int = 100) -> str:
245
- """Format a value for display, truncating if necessary."""
246
- import json
247
-
248
- if value is None:
249
- return "[dim]null[/dim]"
250
-
251
- if isinstance(value, (dict, list)):
252
- # For complex objects, show compact JSON
253
- value_str = json.dumps(value, separators=(',', ':'))
254
- else:
255
- value_str = str(value)
256
-
257
- # Truncate if too long
258
- if len(value_str) > max_length:
259
- return value_str[:max_length-3] + "..."
260
-
261
- return value_str
File without changes
File without changes