acex-cli 4.5.0__tar.gz → 4.5.1__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.5.0
3
+ Version: 4.5.1
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,9 +11,9 @@ 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.3.0,<5.0.0)
15
- Requires-Dist: acex-devkit (>=1.6.2,<2.0.0)
16
- Requires-Dist: acex-driver-cisco-ioscli (>=0.5.1,<0.6.0)
14
+ Requires-Dist: acex-client (>=4.8.1,<5.0.0)
15
+ Requires-Dist: acex-devkit (>=2.4.0,<3.0.0)
16
+ Requires-Dist: acex-driver-cisco-ioscli (>=2.7.0,<3.0.0)
17
17
  Requires-Dist: click (==8.1.7)
18
18
  Requires-Dist: ntc-templates (>=8.1.0,<9.0.0)
19
19
  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.5.0"
3
+ version = "4.5.1"
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,14 +17,18 @@ 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.3.0" # Changes to real dep. by switch_acex_client_dep for publishing
21
- acex-devkit = "^1.6.2"
22
- acex-driver-cisco-ioscli = "^0.5.1"
20
+ acex-client = "^4.8.1" # Changes to real dep. by switch_acex_client_dep for publishing
21
+ acex-devkit = "^2.4.0"
22
+ acex-driver-cisco-ioscli = "^2.7.0"
23
23
  ntc-templates = "^8.1.0"
24
24
 
25
25
  [tool.poetry.scripts]
26
26
  acex = "acex_cli.main:app"
27
27
 
28
+ [tool.poetry.group.dev.dependencies]
29
+ pytest = "^8.0.0"
30
+ pytest-asyncio = "^0.23.0"
31
+
28
32
  [build-system]
29
33
  requires = ["poetry-core>=2.0.0,<3.0.0"]
30
34
  build-backend = "poetry.core.masonry.api"
@@ -1,10 +1,8 @@
1
- from typing import Optional
2
-
3
1
  import typer
2
+ from acex_client.models.generated_models import AssetResponse
4
3
 
5
- from acex_cli.sdk import get_sdk
6
4
  from acex_cli.output import display_list, display_object
7
- from acex_client.models.generated_models import AssetResponse
5
+ from acex_cli.sdk import get_sdk
8
6
 
9
7
  app = typer.Typer(help="Asset resource commands")
10
8
 
@@ -13,25 +11,29 @@ app = typer.Typer(help="Asset resource commands")
13
11
  def list_cmd(
14
12
  ctx: typer.Context,
15
13
  # Filters (match backend query params)
16
- vendor: Optional[str] = typer.Option(None, help="Filter by vendor (prefix match)"),
17
- os: Optional[str] = typer.Option(None, "--os", help="Filter by OS"),
18
- hardware_model: Optional[str] = typer.Option(None, help="Filter by hardware model"),
19
- ned_id: Optional[str] = typer.Option(None, help="Filter by NED ID"),
20
- serial_number: Optional[str] = typer.Option(None, help="Filter by serial number"),
21
- assigned: Optional[bool] = typer.Option(None, help="Filter assigned/unassigned"),
14
+ vendor: str | None = typer.Option(None, help="Filter by vendor (prefix match)"),
15
+ os: str | None = typer.Option(None, "--os", help="Filter by OS"),
16
+ hardware_model: str | None = typer.Option(None, help="Filter by hardware model"),
17
+ ned_id: str | None = typer.Option(None, help="Filter by NED ID"),
18
+ serial_number: str | None = typer.Option(None, help="Filter by serial number"),
19
+ assigned: bool | None = typer.Option(None, help="Filter assigned/unassigned"),
22
20
  # Pagination
23
21
  limit: int = typer.Option(100, "--limit", "-l", help="Max items to return"),
24
22
  offset: int = typer.Option(0, "--offset", help="Items to skip"),
25
23
  # Output
26
24
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
27
- columns: Optional[str] = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
25
+ columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
28
26
  no_header: bool = typer.Option(False, "--no-header", help="Hide table header"),
29
27
  ):
30
28
  """List assets with optional filters."""
31
29
  sdk = get_sdk(ctx.obj.get_active_context())
32
30
  filters = _compact(
33
- vendor=vendor, os=os, hardware_model=hardware_model,
34
- ned_id=ned_id, serial_number=serial_number, assigned=assigned,
31
+ vendor=vendor,
32
+ os=os,
33
+ hardware_model=hardware_model,
34
+ ned_id=ned_id,
35
+ serial_number=serial_number,
36
+ assigned=assigned,
35
37
  )
36
38
  result = sdk.assets.query(limit=limit, offset=offset, **filters)
37
39
  display_list(
@@ -49,7 +51,7 @@ def show_cmd(
49
51
  ctx: typer.Context,
50
52
  asset_id: str,
51
53
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
52
- columns: Optional[str] = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
54
+ columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
53
55
  ):
54
56
  """Show details for an asset."""
55
57
  sdk = get_sdk(ctx.obj.get_active_context())
@@ -1,12 +1,12 @@
1
1
  import typer
2
- from acex_cli.context import CLIContext
3
2
 
4
3
  app = typer.Typer(help="Context management. Configure API url etc.")
5
4
 
5
+
6
6
  @app.command("verify-ssl")
7
7
  def verify_ssl(
8
8
  value: str = typer.Argument(..., help="Set verify_ssl to true or false", show_default=False, case_sensitive=False),
9
- ctx: typer.Context = typer.Context
9
+ ctx: typer.Context = typer.Context,
10
10
  ):
11
11
  """Update verify_ssl for the active context. Usage: acex context verify-ssl true|false"""
12
12
  value = value.lower()
@@ -23,17 +23,19 @@ def verify_ssl(
23
23
  ctx.obj.save()
24
24
  typer.echo(f"verify_ssl for active context '{active}' set to {bool_value}.")
25
25
 
26
+
26
27
  @app.command()
27
28
  def add(
28
29
  name: str = typer.Option(..., "--name", help="Context name"),
29
30
  url: str = typer.Option(..., "--url", help="API URL"),
30
31
  verify_ssl: bool = typer.Option(True, "--verify-ssl/--no-verify-ssl", help="Verify SSL certificates"),
31
- ctx: typer.Context = typer.Context
32
+ ctx: typer.Context = typer.Context,
32
33
  ):
33
34
  """Set or update a context."""
34
35
  ctx.obj.set_context(name, url, verify_ssl=verify_ssl)
35
36
  typer.echo(f"Context '{name}' set to {url} (verify_ssl={verify_ssl}) and activated.")
36
37
 
38
+
37
39
  @app.command()
38
40
  def list(ctx: typer.Context):
39
41
  """List all saved contexts."""
@@ -47,12 +49,14 @@ def list(ctx: typer.Context):
47
49
  url = data.get("url", "")
48
50
  typer.echo(f"{marker} {name}: {url}")
49
51
 
52
+
50
53
  @app.command()
51
54
  def use(name: str, ctx: typer.Context):
52
55
  """Set active context by name."""
53
56
  ctx.obj.set_active(name)
54
57
  typer.echo(f"Active context set to '{name}'")
55
58
 
59
+
56
60
  @app.command()
57
61
  def show(ctx: typer.Context):
58
62
  """Show active context details."""
@@ -61,4 +65,3 @@ def show(ctx: typer.Context):
61
65
  typer.echo(f"Active context: {context}")
62
66
  else:
63
67
  typer.echo("No active context set.")
64
-
@@ -1,10 +1,8 @@
1
- from typing import Optional
2
-
3
1
  import typer
2
+ from acex_client.models.generated_models import LogicalNode
4
3
 
5
- from acex_cli.sdk import get_sdk
6
4
  from acex_cli.output import display_list, display_object
7
- from acex_client.models.generated_models import LogicalNode
5
+ from acex_cli.sdk import get_sdk
8
6
 
9
7
  app = typer.Typer(help="Logical node resource commands")
10
8
 
@@ -13,24 +11,27 @@ app = typer.Typer(help="Logical node resource commands")
13
11
  def list_cmd(
14
12
  ctx: typer.Context,
15
13
  # Filters (match backend query params)
16
- role: Optional[str] = typer.Option(None, help="Filter by role (prefix match)"),
17
- site: Optional[str] = typer.Option(None, help="Filter by site (prefix match)"),
18
- hostname: Optional[str] = typer.Option(None, help="Filter by hostname (prefix match)"),
19
- sequence: Optional[int] = typer.Option(None, help="Filter by sequence number"),
20
- assigned: Optional[bool] = typer.Option(None, help="Filter assigned/unassigned"),
14
+ role: str | None = typer.Option(None, help="Filter by role (prefix match)"),
15
+ site: str | None = typer.Option(None, help="Filter by site (prefix match)"),
16
+ hostname: str | None = typer.Option(None, help="Filter by hostname (prefix match)"),
17
+ sequence: int | None = typer.Option(None, help="Filter by sequence number"),
18
+ assigned: bool | None = typer.Option(None, help="Filter assigned/unassigned"),
21
19
  # Pagination
22
20
  limit: int = typer.Option(100, "--limit", "-l", help="Max items to return"),
23
21
  offset: int = typer.Option(0, "--offset", help="Items to skip"),
24
22
  # Output
25
23
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
26
- columns: Optional[str] = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
24
+ columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
27
25
  no_header: bool = typer.Option(False, "--no-header", help="Hide table header"),
28
26
  ):
29
27
  """List logical nodes with optional filters."""
30
28
  sdk = get_sdk(ctx.obj.get_active_context())
31
29
  filters = _compact(
32
- role=role, site=site, hostname=hostname,
33
- sequence=sequence, assigned=assigned,
30
+ role=role,
31
+ site=site,
32
+ hostname=hostname,
33
+ sequence=sequence,
34
+ assigned=assigned,
34
35
  )
35
36
  result = sdk.logical_nodes.query(limit=limit, offset=offset, **filters)
36
37
  display_list(
@@ -48,7 +49,7 @@ def show_cmd(
48
49
  ctx: typer.Context,
49
50
  logical_node_id: str,
50
51
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
51
- columns: Optional[str] = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
52
+ columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
52
53
  ):
53
54
  """Show details for a logical node."""
54
55
  sdk = get_sdk(ctx.obj.get_active_context())
@@ -1,10 +1,8 @@
1
- from typing import Optional
2
-
3
1
  import typer
2
+ from acex_client.models.generated_models import Ned
4
3
 
5
- from acex_cli.sdk import get_sdk
6
4
  from acex_cli.output import display_list, display_object
7
- from acex_client.models.generated_models import Ned
5
+ from acex_cli.sdk import get_sdk
8
6
 
9
7
  app = typer.Typer(help="NED (Network Element Driver) commands")
10
8
 
@@ -14,7 +12,7 @@ def list_cmd(
14
12
  ctx: typer.Context,
15
13
  # Output (NEDs endpoint has no server-side filters or pagination)
16
14
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
17
- columns: Optional[str] = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
15
+ columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
18
16
  no_header: bool = typer.Option(False, "--no-header", help="Hide table header"),
19
17
  ):
20
18
  """List available NEDs."""
@@ -35,7 +33,7 @@ def show_cmd(
35
33
  ctx: typer.Context,
36
34
  ned_id: str,
37
35
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
38
- columns: Optional[str] = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
36
+ columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
39
37
  ):
40
38
  """Show details for a NED."""
41
39
  sdk = get_sdk(ctx.obj.get_active_context())
@@ -1,27 +1,26 @@
1
1
  import base64
2
2
  import json
3
3
  import os
4
- from typing import Optional
5
4
 
6
5
  import requests
7
6
  import typer
8
-
9
- from acex_cli.sdk import get_sdk
10
- from acex_cli.output import display_list, display_object
11
- from acex_cli.print_utils import print_commands_box
12
7
  from acex_devkit.models import NodeListItem
13
8
  from acex_devkit.models.composed_configuration import ComposedConfiguration
9
+
14
10
  from acex_cli.diff_formatters import (
15
- print_diff_summary,
16
- print_diff_tree,
17
11
  print_diff_compact,
18
12
  print_diff_flat,
13
+ print_diff_summary,
14
+ print_diff_tree,
19
15
  )
20
-
16
+ from acex_cli.output import display_list, display_object
17
+ from acex_cli.print_utils import print_commands_box
18
+ from acex_cli.sdk import get_sdk
21
19
 
22
20
  # ── Temporary REST helpers (replace with SDK methods) ───────────
23
21
 
24
- def _request_get(sdk, path: str, params: Optional[dict] = None) -> requests.Response:
22
+
23
+ def _request_get(sdk, path: str, params: dict | None = None) -> requests.Response:
25
24
  verify = getattr(getattr(sdk, "rest", None), "verify", True)
26
25
  url = f"{sdk.api_url}{path}"
27
26
  response = requests.get(url, params=params, verify=verify)
@@ -30,7 +29,7 @@ def _request_get(sdk, path: str, params: Optional[dict] = None) -> requests.Resp
30
29
 
31
30
 
32
31
  def _get_connection(sdk, node_id):
33
- return _request_get(sdk, f"/inventory/management_connections/?node_id={node_id}").json()
32
+ return _request_get(sdk, f"/inventory/management_connections?node_id={node_id}").json()
34
33
 
35
34
 
36
35
  def _get_rendered_config(sdk, node_id: str) -> str:
@@ -57,6 +56,7 @@ def _fetch_node_instance(sdk, identifier: str):
57
56
  if len(result) > 1:
58
57
  typer.echo(f"Multiple nodes match '{identifier}':")
59
58
  from acex_cli.output import display_list
59
+
60
60
  display_list(result, columns=["id", "hostname", "site", "status"], title="")
61
61
  raise typer.Exit(1)
62
62
  node = sdk.node_instances.get(result.items[0].id)
@@ -83,6 +83,7 @@ def _resolve_node_quick(sdk, identifier: str):
83
83
  if len(result) > 1:
84
84
  typer.echo(f"Multiple nodes match '{identifier}':")
85
85
  from acex_cli.output import display_list
86
+
86
87
  display_list(result, columns=["id", "hostname", "site", "status"], title="")
87
88
  raise typer.Exit(1)
88
89
  return result.items[0]
@@ -90,17 +91,17 @@ def _resolve_node_quick(sdk, identifier: str):
90
91
 
91
92
  def _compile_local(sdk, identifier: str, config_maps_dir: str):
92
93
  """Compile config locally from config maps on disk. Returns ComposedConfiguration."""
93
- import os
94
94
  import importlib.util
95
+ import os
95
96
  import sys
96
97
 
97
98
  try:
98
99
  from acex.config_map import ConfigMap
99
- from acex.configuration import Configuration
100
100
  from acex.config_map.context import ConfigMapContext
101
- except ImportError:
101
+ from acex.configuration import Configuration
102
+ except ImportError as exc:
102
103
  typer.echo("Backend package 'acex' not installed. Run: pip install -e ../backend")
103
- raise typer.Exit(1)
104
+ raise typer.Exit(1) from exc
104
105
 
105
106
  if not os.path.isdir(config_maps_dir):
106
107
  typer.echo(f"Directory not found: {config_maps_dir}")
@@ -195,28 +196,32 @@ config_app.add_typer(config_diff_app, name="diff")
195
196
 
196
197
  # ── Node list / show ────────────────────────────────────────────
197
198
 
199
+
198
200
  @app.command("list")
199
201
  def list_cmd(
200
202
  ctx: typer.Context,
201
203
  # Filters (match backend query params)
202
- site: Optional[str] = typer.Option(None, help="Filter by site (prefix match)"),
203
- hostname: Optional[str] = typer.Option(None, help="Filter by hostname (prefix match)"),
204
- logical_node_id: Optional[int] = typer.Option(None, help="Filter by logical node ID"),
205
- asset_ref_id: Optional[int] = typer.Option(None, help="Filter by asset ref ID"),
206
- status: Optional[str] = typer.Option(None, help="Filter by status (planned, init, active, decommissioned)"),
204
+ site: str | None = typer.Option(None, help="Filter by site (prefix match)"),
205
+ hostname: str | None = typer.Option(None, help="Filter by hostname (prefix match)"),
206
+ logical_node_id: int | None = typer.Option(None, help="Filter by logical node ID"),
207
+ asset_ref_id: int | None = typer.Option(None, help="Filter by asset ref ID"),
208
+ status: str | None = typer.Option(None, help="Filter by status (planned, init, active, decommissioned)"),
207
209
  # Pagination
208
210
  limit: int = typer.Option(100, "--limit", "-l", help="Max items to return"),
209
211
  offset: int = typer.Option(0, "--offset", help="Items to skip"),
210
212
  # Output
211
213
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
212
- columns: Optional[str] = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
214
+ columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
213
215
  no_header: bool = typer.Option(False, "--no-header", help="Hide table header"),
214
216
  ):
215
217
  """List node instances with optional filters."""
216
218
  sdk = get_sdk(ctx.obj.get_active_context())
217
219
  filters = _compact(
218
- site=site, hostname=hostname, logical_node_id=logical_node_id,
219
- asset_ref_id=asset_ref_id, status=status,
220
+ site=site,
221
+ hostname=hostname,
222
+ logical_node_id=logical_node_id,
223
+ asset_ref_id=asset_ref_id,
224
+ status=status,
220
225
  )
221
226
  result = sdk.node_instances.query(limit=limit, offset=offset, **filters)
222
227
  display_list(
@@ -234,7 +239,7 @@ def show_cmd(
234
239
  ctx: typer.Context,
235
240
  node: str = typer.Argument(help="Node ID or hostname"),
236
241
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
237
- columns: Optional[str] = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
242
+ columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
238
243
  ):
239
244
  """Show details for a node instance (by ID or hostname)."""
240
245
  sdk = get_sdk(ctx.obj.get_active_context())
@@ -277,7 +282,7 @@ def connect_cmd(
277
282
  connections = _get_connection(sdk, node_id)
278
283
  except Exception as e:
279
284
  typer.echo(f"Failed to fetch management connection: {e}")
280
- raise typer.Exit(1)
285
+ raise typer.Exit(1) from e
281
286
 
282
287
  if not connections:
283
288
  typer.echo(f"No management connection found for node '{node}'.")
@@ -304,13 +309,14 @@ def connect_cmd(
304
309
 
305
310
  # ── Config show commands ────────────────────────────────────────
306
311
 
312
+
307
313
  @config_show_app.command("local")
308
314
  def config_show_local(
309
315
  ctx: typer.Context,
310
316
  node: str = typer.Argument(help="Node ID or hostname"),
311
317
  dir: str = typer.Option(..., "--dir", "-d", help="Path to local config_maps directory"),
312
318
  render: bool = typer.Option(False, "--render", help="Show rendered device config"),
313
- path: Optional[str] = typer.Option(None, "--path", "-p", help="Drill into section"),
319
+ path: str | None = typer.Option(None, "--path", "-p", help="Drill into section"),
314
320
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
315
321
  ):
316
322
  """Compile config locally from config maps on disk."""
@@ -330,7 +336,7 @@ def config_show_desired(
330
336
  ctx: typer.Context,
331
337
  node: str = typer.Argument(help="Node ID or hostname"),
332
338
  render: bool = typer.Option(False, "--render", help="Show rendered device config"),
333
- path: Optional[str] = typer.Option(None, "--path", "-p", help="Drill into section, e.g. system.ntp, interfaces"),
339
+ path: str | None = typer.Option(None, "--path", "-p", help="Drill into section, e.g. system.ntp, interfaces"),
334
340
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
335
341
  ):
336
342
  """Show desired config."""
@@ -352,7 +358,7 @@ def config_show_observed(
352
358
  ctx: typer.Context,
353
359
  node: str = typer.Argument(help="Node ID or hostname"),
354
360
  render: bool = typer.Option(False, "--render", help="Show rendered device config"),
355
- path: Optional[str] = typer.Option(None, "--path", "-p", help="Drill into section, e.g. system.ntp, interfaces"),
361
+ path: str | None = typer.Option(None, "--path", "-p", help="Drill into section, e.g. system.ntp, interfaces"),
356
362
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
357
363
  ):
358
364
  """Show observed (backup) config."""
@@ -378,21 +384,24 @@ def config_show_observed(
378
384
 
379
385
  # ── Config diff commands ────────────────────────────────────────
380
386
 
387
+
381
388
  @config_diff_app.command("plan")
382
389
  def config_diff_plan(
383
390
  ctx: typer.Context,
384
391
  node: str = typer.Argument(help="Node ID or hostname"),
385
392
  format: str = typer.Option(
386
- "tree", "--format", "-f",
393
+ "tree",
394
+ "--format",
395
+ "-f",
387
396
  help="Output format: tree, compact, flat, summary, json, commands",
388
397
  ),
389
- max_depth: Optional[int] = typer.Option(None, "--max-depth", "-d", help="Max depth for tree view"),
398
+ max_depth: int | None = typer.Option(None, "--max-depth", "-d", help="Max depth for tree view"),
390
399
  no_values: bool = typer.Option(False, "--no-values", help="Hide values in tree view"),
391
400
  ):
392
401
  """Show planned diff between observed and desired config."""
393
402
  sdk = get_sdk(ctx.obj.get_active_context())
394
403
  node_instance, desired_config = _fetch_desired_config(sdk, node)
395
- node_id = str(node_instance.id) if hasattr(node_instance, 'id') and node_instance.id else node
404
+ node_id = str(node_instance.id) if hasattr(node_instance, "id") and node_instance.id else node
396
405
 
397
406
  observed_raw = _get_latest_observed_config(sdk, node_id, "parsed").get("content")
398
407
  observed_config = ComposedConfiguration(**observed_raw)
@@ -425,7 +434,7 @@ def config_diff_apply(
425
434
  """Compute diff and apply config patch to device."""
426
435
  sdk = get_sdk(ctx.obj.get_active_context())
427
436
  node_instance, desired_config = _fetch_desired_config(sdk, node)
428
- node_id = str(node_instance.id) if hasattr(node_instance, 'id') and node_instance.id else node
437
+ node_id = str(node_instance.id) if hasattr(node_instance, "id") and node_instance.id else node
429
438
 
430
439
  observed_raw = _get_latest_observed_config(sdk, node_id, "parsed").get("content")
431
440
  observed_config = ComposedConfiguration(**observed_raw)
@@ -445,11 +454,13 @@ def config_diff_apply(
445
454
 
446
455
  # ── Config display helpers ──────────────────────────────────────
447
456
 
457
+
448
458
  def _print_rendered(text: str):
449
459
  """Print rendered device config with syntax highlighting."""
450
460
  from rich.console import Console
451
- from rich.syntax import Syntax
452
461
  from rich.panel import Panel
462
+ from rich.syntax import Syntax
463
+
453
464
  console = Console()
454
465
  syntax = Syntax(text, "cisco", theme="monokai", line_numbers=True, word_wrap=True)
455
466
  console.print(Panel(syntax, border_style="dim"))
@@ -506,10 +517,11 @@ def _count_components(data) -> int:
506
517
  return count
507
518
 
508
519
 
509
- def _print_parsed_config(data: dict, path: Optional[str], format: str, title: str):
520
+ def _print_parsed_config(data: dict, path: str | None, format: str, title: str):
510
521
  """Display parsed config with AttributeValue awareness."""
511
522
  from rich.console import Console
512
523
  from rich.table import Table
524
+
513
525
  console = Console()
514
526
 
515
527
  if format == "json":
@@ -527,7 +539,11 @@ def _print_parsed_config(data: dict, path: Optional[str], format: str, title: st
527
539
  console.print(f" [dim]source: {source}[/dim]")
528
540
  return
529
541
  # Dict of AttributeValues (e.g. system.config)
530
- if isinstance(section, dict) and section and all(_is_attribute_value(v) for v in section.values() if isinstance(v, dict) and v):
542
+ if (
543
+ isinstance(section, dict)
544
+ and section
545
+ and all(_is_attribute_value(v) for v in section.values() if isinstance(v, dict) and v)
546
+ ):
531
547
  _print_attributes_table(console, section, title=path)
532
548
  return
533
549
  # Container with named entries (e.g. interfaces, ntp.servers)
@@ -553,12 +569,15 @@ def _print_parsed_config(data: dict, path: Optional[str], format: str, title: st
553
569
  )
554
570
 
555
571
  console.print(table)
556
- console.print("[dim]Use --path <section> to drill in, --format json for full dump, --render for device config[/dim]")
572
+ console.print(
573
+ "[dim]Use --path <section> to drill in, --format json for full dump, --render for device config[/dim]"
574
+ )
557
575
 
558
576
 
559
577
  def _print_attributes_table(console, data: dict, title: str = ""):
560
578
  """Print a dict where values are AttributeValues as a clean table."""
561
579
  from rich.table import Table
580
+
562
581
  table = Table(title=title, show_header=True, title_style="bold", title_justify="left")
563
582
  table.add_column("Attribute", style="cyan")
564
583
  table.add_column("Value", style="white")
@@ -578,7 +597,6 @@ def _print_attributes_table(console, data: dict, title: str = ""):
578
597
 
579
598
  def _print_config_section(console, data: dict, path: str):
580
599
  """Print a config section, handling containers and nested AttributeValues."""
581
- from rich.table import Table
582
600
 
583
601
  # Check if entries are named components (e.g. interfaces, ntp.servers)
584
602
  for entry_name, entry_data in data.items():
@@ -611,6 +629,7 @@ def _print_config_section(console, data: dict, path: str):
611
629
 
612
630
  # ── Util ────────────────────────────────────────────────────────
613
631
 
632
+
614
633
  def _flatten_node(node, node_id: str = None) -> dict:
615
634
  """Flatten NodeResponse into a single-level dict for display."""
616
635
  data = node.model_dump() if hasattr(node, "model_dump") else dict(node)
@@ -653,6 +672,7 @@ def _resolve_node(sdk, identifier: str):
653
672
  elif len(result) > 1:
654
673
  typer.echo(f"Multiple nodes match hostname '{identifier}':")
655
674
  from acex_cli.output import display_list
675
+
656
676
  display_list(result, columns=["id", "hostname", "site", "status"], title="")
657
677
  typer.echo("Use the ID to specify which node.")
658
678
  raise typer.Exit(1)
@@ -672,6 +692,7 @@ def _resolve_node_id(sdk, identifier: str) -> str:
672
692
  elif len(result) > 1:
673
693
  typer.echo(f"Multiple nodes match hostname '{identifier}':")
674
694
  from acex_cli.output import display_list
695
+
675
696
  display_list(result, columns=["id", "hostname", "site", "status"], title="")
676
697
  typer.echo("Use the ID to specify which node.")
677
698
  raise typer.Exit(1)
@@ -3,6 +3,7 @@ from pathlib import Path
3
3
 
4
4
  CONTEXT_FILE = Path.home() / ".acex/.acex_cli_context.json"
5
5
 
6
+
6
7
  class CLIContext:
7
8
  def __init__(self):
8
9
  self.data = self._load()
@@ -1,15 +1,16 @@
1
1
  """Formatters for displaying configuration diffs in CLI."""
2
+
2
3
  import json
3
- from rich.tree import Tree
4
- from rich.text import Text
5
- from rich.table import Table
4
+
5
+ from acex_devkit.configdiffer import ComponentDiffOp, Diff
6
+ from pydantic import BaseModel
6
7
  from rich.console import Console
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+ from rich.tree import Tree
7
11
 
8
12
  console = Console()
9
13
 
10
- from pydantic import BaseModel
11
- from acex_devkit.configdiffer import Diff, ComponentDiffOp
12
-
13
14
 
14
15
  def _as_dict(obj) -> dict:
15
16
  """Convert a Pydantic model or dict to a plain dict for iteration."""
@@ -22,54 +23,54 @@ def _as_dict(obj) -> dict:
22
23
 
23
24
  def print_diff_summary(diff):
24
25
  """Print a summary of the diff statistics."""
25
-
26
+
26
27
  if not isinstance(diff, Diff):
27
28
  console.print("[red]Invalid diff object[/red]")
28
29
  return
29
-
30
+
30
31
  if diff.is_empty():
31
32
  console.print("[green]✓ No changes detected[/green]")
32
33
  return
33
-
34
+
34
35
  summary = diff.summary()
35
-
36
+
36
37
  table = Table(title="Configuration Diff Summary")
37
38
  table.add_column("Operation", style="bold")
38
39
  table.add_column("Count", style="cyan", justify="right")
39
-
40
+
40
41
  if summary.get("added", 0) > 0:
41
42
  table.add_row("Added", f"[green]{summary['added']}[/green]")
42
43
  if summary.get("removed", 0) > 0:
43
44
  table.add_row("Removed", f"[red]{summary['removed']}[/red]")
44
45
  if summary.get("changed", 0) > 0:
45
46
  table.add_row("Changed", f"[yellow]{summary['changed']}[/yellow]")
46
-
47
+
47
48
  console.print(table)
48
49
 
49
50
 
50
51
  def print_diff_tree(diff, max_depth: int = None, show_values: bool = True):
51
52
  """
52
53
  Print diff as a hierarchical tree view.
53
-
54
+
54
55
  Args:
55
56
  diff: Diff object from configdiffer
56
57
  max_depth: Maximum depth to show (None for unlimited)
57
58
  show_values: Whether to show before/after values
58
59
  """
59
-
60
+
60
61
  if not isinstance(diff, Diff):
61
62
  console.print("[red]Invalid diff object[/red]")
62
63
  return
63
-
64
+
64
65
  if diff.is_empty():
65
66
  console.print("[green]✓ No changes detected[/green]")
66
67
  return
67
-
68
+
68
69
  # Create colored title with summary
69
70
  summary = diff.summary()
70
71
  title = Text("🔀 Configuration Changes ", style="bold")
71
72
  title.append("(", style="dim")
72
-
73
+
73
74
  parts = []
74
75
  if summary.get("added", 0) > 0:
75
76
  parts.append((f"+{summary['added']}", "green"))
@@ -77,20 +78,20 @@ def print_diff_tree(diff, max_depth: int = None, show_values: bool = True):
77
78
  parts.append((f"-{summary['removed']}", "red"))
78
79
  if summary.get("changed", 0) > 0:
79
80
  parts.append((f"~{summary['changed']}", "yellow"))
80
-
81
+
81
82
  for i, (text, color) in enumerate(parts):
82
83
  if i > 0:
83
84
  title.append(" ", style="dim")
84
85
  title.append(text, style=f"bold {color}")
85
-
86
+
86
87
  title.append(")", style="dim")
87
-
88
+
88
89
  tree = Tree(title)
89
-
90
+
90
91
  # Build a hierarchical tree structure based on component paths
91
92
  # We'll use a dict to track nodes: path_tuple -> tree_node
92
93
  path_nodes = {}
93
-
94
+
94
95
  # Group changes by top-level section first to get counts
95
96
  sections = {}
96
97
  for change in diff.get_all_changes():
@@ -98,28 +99,28 @@ def print_diff_tree(diff, max_depth: int = None, show_values: bool = True):
98
99
  if section not in sections:
99
100
  sections[section] = []
100
101
  sections[section].append(change)
101
-
102
+
102
103
  # Process each change and build nested structure
103
104
  for section_name in sorted(sections.keys()):
104
105
  section_changes = sections[section_name]
105
-
106
+
106
107
  # Create section node
107
108
  section_label = Text()
108
109
  section_label.append(section_name, style="bold cyan")
109
110
  section_label.append(f" ({len(section_changes)} changes)", style="dim")
110
111
  section_node = tree.add(section_label)
111
112
  path_nodes[(section_name,)] = section_node
112
-
113
+
113
114
  # Add each change, building nested path structure
114
115
  for change in section_changes:
115
116
  # Build path incrementally
116
117
  path = tuple(change.path)
117
-
118
+
118
119
  # Find or create parent nodes for nested path
119
120
  parent_node = section_node
120
121
  for i in range(1, len(path)):
121
- partial_path = path[:i+1]
122
-
122
+ partial_path = path[: i + 1]
123
+
123
124
  # Check if this is the final component or an intermediate path
124
125
  if i == len(path) - 1:
125
126
  # This is the actual component with the change
@@ -132,35 +133,37 @@ def print_diff_tree(diff, max_depth: int = None, show_values: bool = True):
132
133
  else: # CHANGE
133
134
  style = "yellow"
134
135
  prefix = "~ "
135
-
136
+
136
137
  # Component label
137
138
  comp_label = Text()
138
139
  comp_label.append(prefix, style=f"bold {style}")
139
140
  comp_label.append(f"{change.component_type}: ", style=f"dim {style}")
140
141
  comp_label.append(change.component_name, style=style)
141
-
142
+
142
143
  comp_node = parent_node.add(comp_label)
143
-
144
+
144
145
  if show_values:
145
146
  # Show attributes for ADD operations
146
147
  if change.op == ComponentDiffOp.ADD and change.after_dict:
147
148
  for attr_name, attr_value in change.after_dict.items():
148
149
  # Skip metadata and type fields
149
- if attr_name in ['metadata', 'type']:
150
+ if attr_name in ["metadata", "type"]:
150
151
  continue
151
152
  attr_label = Text()
152
153
  attr_label.append(" ", style="dim")
153
154
  attr_label.append(attr_name, style="cyan")
154
155
  attr_label.append(": ", style="dim")
155
156
  formatted_val = _format_value(attr_value)
156
- attr_label.append(formatted_val, style="dim green" if formatted_val == "null" else "green")
157
+ attr_label.append(
158
+ formatted_val, style="dim green" if formatted_val == "null" else "green"
159
+ )
157
160
  comp_node.add(attr_label)
158
-
161
+
159
162
  # Show attributes for REMOVE operations
160
163
  elif change.op == ComponentDiffOp.REMOVE and change.before_dict:
161
164
  for attr_name, attr_value in change.before_dict.items():
162
165
  # Skip metadata and type fields
163
- if attr_name in ['metadata', 'type']:
166
+ if attr_name in ["metadata", "type"]:
164
167
  continue
165
168
  attr_label = Text()
166
169
  attr_label.append(" ", style="dim")
@@ -169,7 +172,7 @@ def print_diff_tree(diff, max_depth: int = None, show_values: bool = True):
169
172
  formatted_val = _format_value(attr_value)
170
173
  attr_label.append(formatted_val, style="dim red" if formatted_val == "null" else "red")
171
174
  comp_node.add(attr_label)
172
-
175
+
173
176
  # Show changed attributes for CHANGE operations
174
177
  elif change.op == ComponentDiffOp.CHANGE and change.changed_attributes:
175
178
  for attr in change.changed_attributes:
@@ -191,43 +194,41 @@ def print_diff_tree(diff, max_depth: int = None, show_values: bool = True):
191
194
  path_node = parent_node.add(path_label)
192
195
  path_nodes[partial_path] = path_node
193
196
  parent_node = path_nodes[partial_path]
194
-
197
+
195
198
  console.print(tree)
196
199
 
197
200
 
198
201
  def print_diff_compact(diff, show_unchanged: bool = False):
199
202
  """
200
203
  Print diff in a compact, git-like format.
201
-
204
+
202
205
  Args:
203
206
  diff: Diff object from configdiffer
204
207
  show_unchanged: Whether to show unchanged paths
205
208
  """
206
-
209
+
207
210
  if not isinstance(diff, Diff):
208
211
  console.print("[red]Invalid diff object[/red]")
209
212
  return
210
-
213
+
211
214
  if diff.is_empty():
212
215
  console.print("[green]✓ No changes detected[/green]")
213
216
  return
214
-
217
+
215
218
  if diff.added:
216
219
  console.print("\n[bold green]✚ Added:[/bold green]")
217
220
  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
+ console.print(f" [green]+ {change.component_type_name}:[/green] {change.component_name}")
222
+
221
223
  if diff.removed:
222
224
  console.print("\n[bold red]✖ Removed:[/bold red]")
223
225
  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
-
226
+ console.print(f" [red]- {change.component_type_name}:[/red] {change.component_name}")
227
+
227
228
  if diff.changed:
228
229
  console.print("\n[bold yellow]⚡ Modified:[/bold yellow]")
229
230
  for change in diff.changed:
230
- console.print(f" [yellow]~ {change.component_type_name_name}:[/yellow] {change.component_name}")
231
+ console.print(f" [yellow]~ {change.component_type_name}:[/yellow] {change.component_name}")
231
232
  if change.changed_attributes:
232
233
  for attr in change.changed_attributes:
233
234
  before_str = _format_value(attr.before, max_length=40)
@@ -240,19 +241,19 @@ def print_diff_compact(diff, show_unchanged: bool = False):
240
241
  def print_diff_flat(diff):
241
242
  """
242
243
  Print diff in a flat list format, good for quick scanning.
243
-
244
+
244
245
  Args:
245
246
  diff: Diff object from configdiffer
246
247
  """
247
-
248
+
248
249
  if not isinstance(diff, Diff):
249
250
  console.print("[red]Invalid diff object[/red]")
250
251
  return
251
-
252
+
252
253
  if diff.is_empty():
253
254
  console.print("[green]✓ No changes detected[/green]")
254
255
  return
255
-
256
+
256
257
  # Create table
257
258
  table = Table(title="Configuration Changes", show_lines=True)
258
259
  table.add_column("Op", style="bold", width=3)
@@ -261,69 +262,69 @@ def print_diff_flat(diff):
261
262
  table.add_column("Attribute", style="magenta")
262
263
  table.add_column("Before", style="red", max_width=30)
263
264
  table.add_column("After", style="green", max_width=30)
264
-
265
+
265
266
  for change in diff.added:
266
267
  table.add_row(
267
268
  "[green]+[/green]",
268
- change.component_type_name_name,
269
+ change.component_type_name,
269
270
  change.component_name,
270
271
  "[dim]--[/dim]",
271
272
  "",
272
- "[dim]new component[/dim]"
273
+ "[dim]new component[/dim]",
273
274
  )
274
-
275
+
275
276
  for change in diff.removed:
276
277
  table.add_row(
277
278
  "[red]-[/red]",
278
- change.component_type_name_name,
279
+ change.component_type_name,
279
280
  change.component_name,
280
281
  "[dim]--[/dim]",
281
282
  "[dim]component deleted[/dim]",
282
- ""
283
+ "",
283
284
  )
284
-
285
+
285
286
  for change in diff.changed:
286
287
  if change.changed_attributes:
287
288
  for i, attr in enumerate(change.changed_attributes):
288
289
  table.add_row(
289
290
  "[yellow]~[/yellow]" if i == 0 else "",
290
- change.component_type_name_name if i == 0 else "",
291
+ change.component_type_name if i == 0 else "",
291
292
  change.component_name if i == 0 else "",
292
293
  attr.attribute_name,
293
294
  _format_value(attr.before, max_length=30),
294
- _format_value(attr.after, max_length=30)
295
+ _format_value(attr.after, max_length=30),
295
296
  )
296
297
  else:
297
298
  table.add_row(
298
299
  "[yellow]~[/yellow]",
299
- change.component_type_name_name,
300
+ change.component_type_name,
300
301
  change.component_name,
301
302
  "[dim]--[/dim]",
302
303
  "[dim]changed[/dim]",
303
- "[dim]changed[/dim]"
304
+ "[dim]changed[/dim]",
304
305
  )
305
-
306
+
306
307
  console.print(table)
307
308
 
308
309
 
309
310
  def _format_value(value, max_length: int = 100) -> str:
310
311
  """Format a value for display, truncating if necessary."""
311
-
312
+
312
313
  if value is None:
313
314
  return "null"
314
-
315
+
315
316
  # Check if this is an AttributeValue (Pydantic model or dict with 'value' key)
316
- if isinstance(value, BaseModel) and hasattr(value, 'value'):
317
+ if isinstance(value, BaseModel) and hasattr(value, "value"):
317
318
  value = value.value
318
319
  if value is None:
319
320
  return "null"
320
- elif isinstance(value, dict) and 'value' in value:
321
- value = value['value']
321
+ elif isinstance(value, dict) and "value" in value:
322
+ value = value["value"]
322
323
  if value is None:
323
324
  return "null"
324
-
325
+
325
326
  if isinstance(value, BaseModel):
326
- value_str = json.dumps(value.model_dump(), separators=(',', ':'))
327
+ value_str = json.dumps(value.model_dump(), separators=(",", ":"))
327
328
  elif isinstance(value, (dict, list)):
328
329
  # Convert any nested BaseModel instances before serializing
329
330
  def _to_serializable(obj):
@@ -334,12 +335,13 @@ def _format_value(value, max_length: int = 100) -> str:
334
335
  if isinstance(obj, list):
335
336
  return [_to_serializable(i) for i in obj]
336
337
  return obj
337
- value_str = json.dumps(_to_serializable(value), separators=(',', ':'))
338
+
339
+ value_str = json.dumps(_to_serializable(value), separators=(",", ":"))
338
340
  else:
339
341
  value_str = str(value)
340
-
342
+
341
343
  # Truncate if too long
342
344
  if len(value_str) > max_length:
343
- return value_str[:max_length-3] + "..."
344
-
345
+ return value_str[: max_length - 3] + "..."
346
+
345
347
  return value_str
@@ -1,16 +1,16 @@
1
1
  """Main CLI application entry point."""
2
2
 
3
- import typer
4
3
  import importlib
5
- import pkgutil
6
4
  import pathlib
5
+ import pkgutil
6
+
7
+ import typer
8
+
7
9
  from acex_cli.context import CLIContext
10
+ from acex_cli.output import console
8
11
 
9
12
  app = typer.Typer(
10
- name="acex",
11
- help="ACE-X - Automation & Control Ecosystem CLI",
12
- add_completion=True,
13
- pretty_exceptions_enable=False
13
+ name="acex", help="ACE-X - Automation & Control Ecosystem CLI", add_completion=True, pretty_exceptions_enable=False
14
14
  )
15
15
 
16
16
 
@@ -38,9 +38,9 @@ def main(ctx: typer.Context):
38
38
  def version():
39
39
  """Show version information."""
40
40
  from acex_cli import __version__
41
+
41
42
  console.print(f"ACE-X CLI version: {__version__}")
42
43
 
43
44
 
44
45
  if __name__ == "__main__":
45
46
  app()
46
-
@@ -7,10 +7,9 @@ header control, and pagination display.
7
7
  import csv
8
8
  import io
9
9
  import json
10
- from typing import Optional
11
10
 
12
- from rich.table import Table
13
11
  from rich.console import Console
12
+ from rich.table import Table
14
13
 
15
14
  console = Console()
16
15
 
@@ -19,7 +18,7 @@ def display_list(
19
18
  result,
20
19
  *,
21
20
  format: str = "table",
22
- columns: Optional[list[str]] = None,
21
+ columns: list[str] | None = None,
23
22
  no_header: bool = False,
24
23
  model=None,
25
24
  title: str = "",
@@ -47,7 +46,10 @@ def display_list(
47
46
  _print_csv(rows, columns, no_header)
48
47
  else:
49
48
  _print_table(
50
- rows, columns, no_header, title,
49
+ rows,
50
+ columns,
51
+ no_header,
52
+ title,
51
53
  total=total,
52
54
  limit=getattr(result, "limit", None),
53
55
  offset=getattr(result, "offset", None),
@@ -58,8 +60,8 @@ def display_object(
58
60
  obj,
59
61
  *,
60
62
  format: str = "table",
61
- columns: Optional[list[str]] = None,
62
- exclude: Optional[list[str]] = None,
63
+ columns: list[str] | None = None,
64
+ exclude: list[str] | None = None,
63
65
  model=None,
64
66
  title: str = "",
65
67
  ):
@@ -221,6 +223,7 @@ def _format_cell(value) -> str:
221
223
  if value is None:
222
224
  return ""
223
225
  from enum import Enum
226
+
224
227
  if isinstance(value, Enum):
225
228
  return value.value
226
229
  if isinstance(value, dict) and not value:
@@ -1,6 +1,4 @@
1
1
  from acex_client.acex.acex import Acex
2
- import requests
3
- from requests.exceptions import ConnectionError, SSLError
4
2
 
5
3
 
6
4
  def _innermost_exception_message(exc: Exception) -> str:
@@ -16,7 +14,6 @@ def get_sdk(context):
16
14
  url = context.get("url")
17
15
  if not url:
18
16
  raise RuntimeError("Active context saknar 'url'.")
19
- jwt = context.get("jwt")
20
17
 
21
18
  verify = context.get("verify_ssl", True)
22
19
  client = Acex(baseurl=f"{url}/", verify=verify)
File without changes