acex-cli 4.4.2__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.4.2
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.0,<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.4.2"
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.0"
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,26 +1,26 @@
1
1
  import base64
2
2
  import json
3
- from typing import Optional
3
+ import os
4
4
 
5
5
  import requests
6
6
  import typer
7
-
8
- from acex_cli.sdk import get_sdk
9
- from acex_cli.output import display_list, display_object
10
- from acex_cli.print_utils import print_commands_box
11
7
  from acex_devkit.models import NodeListItem
12
8
  from acex_devkit.models.composed_configuration import ComposedConfiguration
9
+
13
10
  from acex_cli.diff_formatters import (
14
- print_diff_summary,
15
- print_diff_tree,
16
11
  print_diff_compact,
17
12
  print_diff_flat,
13
+ print_diff_summary,
14
+ print_diff_tree,
18
15
  )
19
-
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
20
19
 
21
20
  # ── Temporary REST helpers (replace with SDK methods) ───────────
22
21
 
23
- 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:
24
24
  verify = getattr(getattr(sdk, "rest", None), "verify", True)
25
25
  url = f"{sdk.api_url}{path}"
26
26
  response = requests.get(url, params=params, verify=verify)
@@ -29,7 +29,7 @@ def _request_get(sdk, path: str, params: Optional[dict] = None) -> requests.Resp
29
29
 
30
30
 
31
31
  def _get_connection(sdk, node_id):
32
- 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()
33
33
 
34
34
 
35
35
  def _get_rendered_config(sdk, node_id: str) -> str:
@@ -56,6 +56,7 @@ def _fetch_node_instance(sdk, identifier: str):
56
56
  if len(result) > 1:
57
57
  typer.echo(f"Multiple nodes match '{identifier}':")
58
58
  from acex_cli.output import display_list
59
+
59
60
  display_list(result, columns=["id", "hostname", "site", "status"], title="")
60
61
  raise typer.Exit(1)
61
62
  node = sdk.node_instances.get(result.items[0].id)
@@ -82,6 +83,7 @@ def _resolve_node_quick(sdk, identifier: str):
82
83
  if len(result) > 1:
83
84
  typer.echo(f"Multiple nodes match '{identifier}':")
84
85
  from acex_cli.output import display_list
86
+
85
87
  display_list(result, columns=["id", "hostname", "site", "status"], title="")
86
88
  raise typer.Exit(1)
87
89
  return result.items[0]
@@ -89,17 +91,17 @@ def _resolve_node_quick(sdk, identifier: str):
89
91
 
90
92
  def _compile_local(sdk, identifier: str, config_maps_dir: str):
91
93
  """Compile config locally from config maps on disk. Returns ComposedConfiguration."""
92
- import os
93
94
  import importlib.util
95
+ import os
94
96
  import sys
95
97
 
96
98
  try:
97
99
  from acex.config_map import ConfigMap
98
- from acex.configuration import Configuration
99
100
  from acex.config_map.context import ConfigMapContext
100
- except ImportError:
101
+ from acex.configuration import Configuration
102
+ except ImportError as exc:
101
103
  typer.echo("Backend package 'acex' not installed. Run: pip install -e ../backend")
102
- raise typer.Exit(1)
104
+ raise typer.Exit(1) from exc
103
105
 
104
106
  if not os.path.isdir(config_maps_dir):
105
107
  typer.echo(f"Directory not found: {config_maps_dir}")
@@ -194,28 +196,32 @@ config_app.add_typer(config_diff_app, name="diff")
194
196
 
195
197
  # ── Node list / show ────────────────────────────────────────────
196
198
 
199
+
197
200
  @app.command("list")
198
201
  def list_cmd(
199
202
  ctx: typer.Context,
200
203
  # Filters (match backend query params)
201
- site: Optional[str] = typer.Option(None, help="Filter by site (prefix match)"),
202
- hostname: Optional[str] = typer.Option(None, help="Filter by hostname (prefix match)"),
203
- logical_node_id: Optional[int] = typer.Option(None, help="Filter by logical node ID"),
204
- asset_ref_id: Optional[int] = typer.Option(None, help="Filter by asset ref ID"),
205
- 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)"),
206
209
  # Pagination
207
210
  limit: int = typer.Option(100, "--limit", "-l", help="Max items to return"),
208
211
  offset: int = typer.Option(0, "--offset", help="Items to skip"),
209
212
  # Output
210
213
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
211
- 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"),
212
215
  no_header: bool = typer.Option(False, "--no-header", help="Hide table header"),
213
216
  ):
214
217
  """List node instances with optional filters."""
215
218
  sdk = get_sdk(ctx.obj.get_active_context())
216
219
  filters = _compact(
217
- site=site, hostname=hostname, logical_node_id=logical_node_id,
218
- 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,
219
225
  )
220
226
  result = sdk.node_instances.query(limit=limit, offset=offset, **filters)
221
227
  display_list(
@@ -233,7 +239,7 @@ def show_cmd(
233
239
  ctx: typer.Context,
234
240
  node: str = typer.Argument(help="Node ID or hostname"),
235
241
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
236
- 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"),
237
243
  ):
238
244
  """Show details for a node instance (by ID or hostname)."""
239
245
  sdk = get_sdk(ctx.obj.get_active_context())
@@ -263,15 +269,54 @@ def show_cmd(
263
269
  )
264
270
 
265
271
 
272
+ @app.command("connect")
273
+ def connect_cmd(
274
+ ctx: typer.Context,
275
+ node: str = typer.Argument(help="Node ID or hostname"),
276
+ ):
277
+ """Connect to a node via its management connection (SSH)."""
278
+ sdk = get_sdk(ctx.obj.get_active_context())
279
+ node_id = _resolve_node_id(sdk, node)
280
+
281
+ try:
282
+ connections = _get_connection(sdk, node_id)
283
+ except Exception as e:
284
+ typer.echo(f"Failed to fetch management connection: {e}")
285
+ raise typer.Exit(1) from e
286
+
287
+ if not connections:
288
+ typer.echo(f"No management connection found for node '{node}'.")
289
+ raise typer.Exit(1)
290
+
291
+ conn = connections[0]
292
+ connection_type = (conn.get("connection_type") or "").lower()
293
+
294
+ if connection_type != "ssh":
295
+ typer.echo(f"Unsupported connection type '{connection_type}'. Only SSH is supported.")
296
+ raise typer.Exit(1)
297
+
298
+ target_ip = conn.get("target_ip")
299
+
300
+ if not target_ip:
301
+ typer.echo("Management connection has no target_ip.")
302
+ raise typer.Exit(1)
303
+
304
+ ssh_args = ["ssh", target_ip]
305
+
306
+ typer.echo(f"Connecting to {target_ip} via SSH...")
307
+ os.execvp("ssh", ssh_args)
308
+
309
+
266
310
  # ── Config show commands ────────────────────────────────────────
267
311
 
312
+
268
313
  @config_show_app.command("local")
269
314
  def config_show_local(
270
315
  ctx: typer.Context,
271
316
  node: str = typer.Argument(help="Node ID or hostname"),
272
317
  dir: str = typer.Option(..., "--dir", "-d", help="Path to local config_maps directory"),
273
318
  render: bool = typer.Option(False, "--render", help="Show rendered device config"),
274
- 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"),
275
320
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
276
321
  ):
277
322
  """Compile config locally from config maps on disk."""
@@ -291,7 +336,7 @@ def config_show_desired(
291
336
  ctx: typer.Context,
292
337
  node: str = typer.Argument(help="Node ID or hostname"),
293
338
  render: bool = typer.Option(False, "--render", help="Show rendered device config"),
294
- 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"),
295
340
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
296
341
  ):
297
342
  """Show desired config."""
@@ -313,7 +358,7 @@ def config_show_observed(
313
358
  ctx: typer.Context,
314
359
  node: str = typer.Argument(help="Node ID or hostname"),
315
360
  render: bool = typer.Option(False, "--render", help="Show rendered device config"),
316
- 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"),
317
362
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
318
363
  ):
319
364
  """Show observed (backup) config."""
@@ -339,21 +384,24 @@ def config_show_observed(
339
384
 
340
385
  # ── Config diff commands ────────────────────────────────────────
341
386
 
387
+
342
388
  @config_diff_app.command("plan")
343
389
  def config_diff_plan(
344
390
  ctx: typer.Context,
345
391
  node: str = typer.Argument(help="Node ID or hostname"),
346
392
  format: str = typer.Option(
347
- "tree", "--format", "-f",
393
+ "tree",
394
+ "--format",
395
+ "-f",
348
396
  help="Output format: tree, compact, flat, summary, json, commands",
349
397
  ),
350
- 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"),
351
399
  no_values: bool = typer.Option(False, "--no-values", help="Hide values in tree view"),
352
400
  ):
353
401
  """Show planned diff between observed and desired config."""
354
402
  sdk = get_sdk(ctx.obj.get_active_context())
355
403
  node_instance, desired_config = _fetch_desired_config(sdk, node)
356
- 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
357
405
 
358
406
  observed_raw = _get_latest_observed_config(sdk, node_id, "parsed").get("content")
359
407
  observed_config = ComposedConfiguration(**observed_raw)
@@ -386,7 +434,7 @@ def config_diff_apply(
386
434
  """Compute diff and apply config patch to device."""
387
435
  sdk = get_sdk(ctx.obj.get_active_context())
388
436
  node_instance, desired_config = _fetch_desired_config(sdk, node)
389
- 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
390
438
 
391
439
  observed_raw = _get_latest_observed_config(sdk, node_id, "parsed").get("content")
392
440
  observed_config = ComposedConfiguration(**observed_raw)
@@ -406,11 +454,13 @@ def config_diff_apply(
406
454
 
407
455
  # ── Config display helpers ──────────────────────────────────────
408
456
 
457
+
409
458
  def _print_rendered(text: str):
410
459
  """Print rendered device config with syntax highlighting."""
411
460
  from rich.console import Console
412
- from rich.syntax import Syntax
413
461
  from rich.panel import Panel
462
+ from rich.syntax import Syntax
463
+
414
464
  console = Console()
415
465
  syntax = Syntax(text, "cisco", theme="monokai", line_numbers=True, word_wrap=True)
416
466
  console.print(Panel(syntax, border_style="dim"))
@@ -467,10 +517,11 @@ def _count_components(data) -> int:
467
517
  return count
468
518
 
469
519
 
470
- 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):
471
521
  """Display parsed config with AttributeValue awareness."""
472
522
  from rich.console import Console
473
523
  from rich.table import Table
524
+
474
525
  console = Console()
475
526
 
476
527
  if format == "json":
@@ -488,7 +539,11 @@ def _print_parsed_config(data: dict, path: Optional[str], format: str, title: st
488
539
  console.print(f" [dim]source: {source}[/dim]")
489
540
  return
490
541
  # Dict of AttributeValues (e.g. system.config)
491
- 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
+ ):
492
547
  _print_attributes_table(console, section, title=path)
493
548
  return
494
549
  # Container with named entries (e.g. interfaces, ntp.servers)
@@ -514,12 +569,15 @@ def _print_parsed_config(data: dict, path: Optional[str], format: str, title: st
514
569
  )
515
570
 
516
571
  console.print(table)
517
- 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
+ )
518
575
 
519
576
 
520
577
  def _print_attributes_table(console, data: dict, title: str = ""):
521
578
  """Print a dict where values are AttributeValues as a clean table."""
522
579
  from rich.table import Table
580
+
523
581
  table = Table(title=title, show_header=True, title_style="bold", title_justify="left")
524
582
  table.add_column("Attribute", style="cyan")
525
583
  table.add_column("Value", style="white")
@@ -539,7 +597,6 @@ def _print_attributes_table(console, data: dict, title: str = ""):
539
597
 
540
598
  def _print_config_section(console, data: dict, path: str):
541
599
  """Print a config section, handling containers and nested AttributeValues."""
542
- from rich.table import Table
543
600
 
544
601
  # Check if entries are named components (e.g. interfaces, ntp.servers)
545
602
  for entry_name, entry_data in data.items():
@@ -572,6 +629,7 @@ def _print_config_section(console, data: dict, path: str):
572
629
 
573
630
  # ── Util ────────────────────────────────────────────────────────
574
631
 
632
+
575
633
  def _flatten_node(node, node_id: str = None) -> dict:
576
634
  """Flatten NodeResponse into a single-level dict for display."""
577
635
  data = node.model_dump() if hasattr(node, "model_dump") else dict(node)
@@ -614,6 +672,7 @@ def _resolve_node(sdk, identifier: str):
614
672
  elif len(result) > 1:
615
673
  typer.echo(f"Multiple nodes match hostname '{identifier}':")
616
674
  from acex_cli.output import display_list
675
+
617
676
  display_list(result, columns=["id", "hostname", "site", "status"], title="")
618
677
  typer.echo("Use the ID to specify which node.")
619
678
  raise typer.Exit(1)
@@ -633,6 +692,7 @@ def _resolve_node_id(sdk, identifier: str) -> str:
633
692
  elif len(result) > 1:
634
693
  typer.echo(f"Multiple nodes match hostname '{identifier}':")
635
694
  from acex_cli.output import display_list
695
+
636
696
  display_list(result, columns=["id", "hostname", "site", "status"], title="")
637
697
  typer.echo("Use the ID to specify which node.")
638
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