acex-cli 4.5.0__tar.gz → 4.6.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.
- {acex_cli-4.5.0 → acex_cli-4.6.0}/PKG-INFO +9 -8
- {acex_cli-4.5.0 → acex_cli-4.6.0}/pyproject.toml +12 -8
- acex_cli-4.6.0/src/acex_cli/__init__.py +5 -0
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/commands/asset.py +16 -14
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/commands/context.py +7 -4
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/commands/logical_node.py +14 -13
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/commands/ned.py +4 -6
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/commands/node.py +71 -42
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/context.py +1 -0
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/diff_formatters.py +77 -75
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/main.py +7 -7
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/output.py +9 -6
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/sdk.py +0 -3
- acex_cli-4.5.0/src/acex_cli/__init__.py +0 -3
- {acex_cli-4.5.0 → acex_cli-4.6.0}/README.md +0 -0
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/commands/__init__.py +0 -0
- {acex_cli-4.5.0 → acex_cli-4.6.0}/src/acex_cli/print_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: acex-cli
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.6.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,13 +11,14 @@ 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
|
-
|
|
15
|
-
Requires-Dist: acex-
|
|
16
|
-
Requires-Dist: acex-
|
|
17
|
-
Requires-Dist:
|
|
18
|
-
Requires-Dist:
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
15
|
+
Requires-Dist: acex-client (>=4.11.0,<5.0.0)
|
|
16
|
+
Requires-Dist: acex-devkit (>=2.11.0,<3.0.0)
|
|
17
|
+
Requires-Dist: acex-driver-cisco-ioscli (>=2.7.4,<3.0.0)
|
|
18
|
+
Requires-Dist: click (>=8.1.7)
|
|
19
|
+
Requires-Dist: ntc-templates (>=8.1.0)
|
|
20
|
+
Requires-Dist: rich (>=13.0.0)
|
|
21
|
+
Requires-Dist: typer (>=0.12.0)
|
|
21
22
|
Project-URL: Homepage, https://github.com/acex-labs/acex
|
|
22
23
|
Project-URL: Repository, https://github.com/acex-labs/acex
|
|
23
24
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "acex-cli"
|
|
3
|
-
version = "4.
|
|
3
|
+
version = "4.6.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"
|
|
@@ -14,17 +14,21 @@ packages = [
|
|
|
14
14
|
|
|
15
15
|
[tool.poetry.dependencies]
|
|
16
16
|
python = "^3.13"
|
|
17
|
-
typer = "
|
|
18
|
-
rich = "
|
|
19
|
-
click = "8.1.7"
|
|
20
|
-
acex-client = "^4.
|
|
21
|
-
acex-devkit = "^
|
|
22
|
-
acex-driver-cisco-ioscli = "^
|
|
23
|
-
ntc-templates = "
|
|
17
|
+
typer = ">=0.12.0"
|
|
18
|
+
rich = ">=13.0.0"
|
|
19
|
+
click = ">=8.1.7"
|
|
20
|
+
acex-client = "^4.11.0" # Changes to real dep. by switch_acex_client_dep for publishing
|
|
21
|
+
acex-devkit = "^2.11.0"
|
|
22
|
+
acex-driver-cisco-ioscli = "^2.7.4"
|
|
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
|
|
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:
|
|
17
|
-
os:
|
|
18
|
-
hardware_model:
|
|
19
|
-
ned_id:
|
|
20
|
-
serial_number:
|
|
21
|
-
assigned:
|
|
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:
|
|
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,
|
|
34
|
-
|
|
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:
|
|
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
|
|
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:
|
|
17
|
-
site:
|
|
18
|
-
hostname:
|
|
19
|
-
sequence:
|
|
20
|
-
assigned:
|
|
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:
|
|
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,
|
|
33
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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,7 +56,8 @@ 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
|
|
60
|
-
|
|
59
|
+
|
|
60
|
+
display_list(result, columns=["id", "hostname", "site", "admin_status", "provision_status"], title="")
|
|
61
61
|
raise typer.Exit(1)
|
|
62
62
|
node = sdk.node_instances.get(result.items[0].id)
|
|
63
63
|
if not node:
|
|
@@ -83,24 +83,25 @@ 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", "admin_status", "provision_status"], title="")
|
|
87
88
|
raise typer.Exit(1)
|
|
88
89
|
return result.items[0]
|
|
89
90
|
|
|
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
|
-
|
|
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,34 +196,45 @@ 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:
|
|
203
|
-
hostname:
|
|
204
|
-
logical_node_id:
|
|
205
|
-
asset_ref_id:
|
|
206
|
-
|
|
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
|
+
admin_status: str | None = typer.Option(None, help="Filter by admin status (planned, active, decommissioned)"),
|
|
209
|
+
provision_status: str | None = typer.Option(
|
|
210
|
+
None,
|
|
211
|
+
help=("Filter by provision status (unprovisioned, adopted, enrolled, provisioning, provisioned, failed)"),
|
|
212
|
+
),
|
|
207
213
|
# Pagination
|
|
208
214
|
limit: int = typer.Option(100, "--limit", "-l", help="Max items to return"),
|
|
209
215
|
offset: int = typer.Option(0, "--offset", help="Items to skip"),
|
|
210
216
|
# Output
|
|
211
217
|
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
|
|
212
|
-
columns:
|
|
218
|
+
columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated columns"),
|
|
213
219
|
no_header: bool = typer.Option(False, "--no-header", help="Hide table header"),
|
|
214
220
|
):
|
|
215
221
|
"""List node instances with optional filters."""
|
|
216
222
|
sdk = get_sdk(ctx.obj.get_active_context())
|
|
217
223
|
filters = _compact(
|
|
218
|
-
site=site,
|
|
219
|
-
|
|
224
|
+
site=site,
|
|
225
|
+
hostname=hostname,
|
|
226
|
+
logical_node_id=logical_node_id,
|
|
227
|
+
asset_ref_id=asset_ref_id,
|
|
228
|
+
admin_status=admin_status,
|
|
229
|
+
provision_status=provision_status,
|
|
220
230
|
)
|
|
221
231
|
result = sdk.node_instances.query(limit=limit, offset=offset, **filters)
|
|
222
232
|
display_list(
|
|
223
233
|
result,
|
|
224
234
|
format=format,
|
|
225
|
-
columns=columns.split(",")
|
|
235
|
+
columns=columns.split(",")
|
|
236
|
+
if columns
|
|
237
|
+
else ["id", "hostname", "site", "admin_status", "provision_status", "vendor", "os", "ned_id"],
|
|
226
238
|
no_header=no_header,
|
|
227
239
|
model=NodeListItem,
|
|
228
240
|
title="Node Instances",
|
|
@@ -234,7 +246,7 @@ def show_cmd(
|
|
|
234
246
|
ctx: typer.Context,
|
|
235
247
|
node: str = typer.Argument(help="Node ID or hostname"),
|
|
236
248
|
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
|
|
237
|
-
columns:
|
|
249
|
+
columns: str | None = typer.Option(None, "--columns", "-c", help="Comma-separated fields"),
|
|
238
250
|
):
|
|
239
251
|
"""Show details for a node instance (by ID or hostname)."""
|
|
240
252
|
sdk = get_sdk(ctx.obj.get_active_context())
|
|
@@ -277,7 +289,7 @@ def connect_cmd(
|
|
|
277
289
|
connections = _get_connection(sdk, node_id)
|
|
278
290
|
except Exception as e:
|
|
279
291
|
typer.echo(f"Failed to fetch management connection: {e}")
|
|
280
|
-
raise typer.Exit(1)
|
|
292
|
+
raise typer.Exit(1) from e
|
|
281
293
|
|
|
282
294
|
if not connections:
|
|
283
295
|
typer.echo(f"No management connection found for node '{node}'.")
|
|
@@ -304,13 +316,14 @@ def connect_cmd(
|
|
|
304
316
|
|
|
305
317
|
# ── Config show commands ────────────────────────────────────────
|
|
306
318
|
|
|
319
|
+
|
|
307
320
|
@config_show_app.command("local")
|
|
308
321
|
def config_show_local(
|
|
309
322
|
ctx: typer.Context,
|
|
310
323
|
node: str = typer.Argument(help="Node ID or hostname"),
|
|
311
324
|
dir: str = typer.Option(..., "--dir", "-d", help="Path to local config_maps directory"),
|
|
312
325
|
render: bool = typer.Option(False, "--render", help="Show rendered device config"),
|
|
313
|
-
path:
|
|
326
|
+
path: str | None = typer.Option(None, "--path", "-p", help="Drill into section"),
|
|
314
327
|
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
315
328
|
):
|
|
316
329
|
"""Compile config locally from config maps on disk."""
|
|
@@ -330,7 +343,7 @@ def config_show_desired(
|
|
|
330
343
|
ctx: typer.Context,
|
|
331
344
|
node: str = typer.Argument(help="Node ID or hostname"),
|
|
332
345
|
render: bool = typer.Option(False, "--render", help="Show rendered device config"),
|
|
333
|
-
path:
|
|
346
|
+
path: str | None = typer.Option(None, "--path", "-p", help="Drill into section, e.g. system.ntp, interfaces"),
|
|
334
347
|
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
335
348
|
):
|
|
336
349
|
"""Show desired config."""
|
|
@@ -352,7 +365,7 @@ def config_show_observed(
|
|
|
352
365
|
ctx: typer.Context,
|
|
353
366
|
node: str = typer.Argument(help="Node ID or hostname"),
|
|
354
367
|
render: bool = typer.Option(False, "--render", help="Show rendered device config"),
|
|
355
|
-
path:
|
|
368
|
+
path: str | None = typer.Option(None, "--path", "-p", help="Drill into section, e.g. system.ntp, interfaces"),
|
|
356
369
|
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
357
370
|
):
|
|
358
371
|
"""Show observed (backup) config."""
|
|
@@ -378,21 +391,24 @@ def config_show_observed(
|
|
|
378
391
|
|
|
379
392
|
# ── Config diff commands ────────────────────────────────────────
|
|
380
393
|
|
|
394
|
+
|
|
381
395
|
@config_diff_app.command("plan")
|
|
382
396
|
def config_diff_plan(
|
|
383
397
|
ctx: typer.Context,
|
|
384
398
|
node: str = typer.Argument(help="Node ID or hostname"),
|
|
385
399
|
format: str = typer.Option(
|
|
386
|
-
"tree",
|
|
400
|
+
"tree",
|
|
401
|
+
"--format",
|
|
402
|
+
"-f",
|
|
387
403
|
help="Output format: tree, compact, flat, summary, json, commands",
|
|
388
404
|
),
|
|
389
|
-
max_depth:
|
|
405
|
+
max_depth: int | None = typer.Option(None, "--max-depth", "-d", help="Max depth for tree view"),
|
|
390
406
|
no_values: bool = typer.Option(False, "--no-values", help="Hide values in tree view"),
|
|
391
407
|
):
|
|
392
408
|
"""Show planned diff between observed and desired config."""
|
|
393
409
|
sdk = get_sdk(ctx.obj.get_active_context())
|
|
394
410
|
node_instance, desired_config = _fetch_desired_config(sdk, node)
|
|
395
|
-
node_id = str(node_instance.id) if hasattr(node_instance,
|
|
411
|
+
node_id = str(node_instance.id) if hasattr(node_instance, "id") and node_instance.id else node
|
|
396
412
|
|
|
397
413
|
observed_raw = _get_latest_observed_config(sdk, node_id, "parsed").get("content")
|
|
398
414
|
observed_config = ComposedConfiguration(**observed_raw)
|
|
@@ -425,7 +441,7 @@ def config_diff_apply(
|
|
|
425
441
|
"""Compute diff and apply config patch to device."""
|
|
426
442
|
sdk = get_sdk(ctx.obj.get_active_context())
|
|
427
443
|
node_instance, desired_config = _fetch_desired_config(sdk, node)
|
|
428
|
-
node_id = str(node_instance.id) if hasattr(node_instance,
|
|
444
|
+
node_id = str(node_instance.id) if hasattr(node_instance, "id") and node_instance.id else node
|
|
429
445
|
|
|
430
446
|
observed_raw = _get_latest_observed_config(sdk, node_id, "parsed").get("content")
|
|
431
447
|
observed_config = ComposedConfiguration(**observed_raw)
|
|
@@ -445,11 +461,13 @@ def config_diff_apply(
|
|
|
445
461
|
|
|
446
462
|
# ── Config display helpers ──────────────────────────────────────
|
|
447
463
|
|
|
464
|
+
|
|
448
465
|
def _print_rendered(text: str):
|
|
449
466
|
"""Print rendered device config with syntax highlighting."""
|
|
450
467
|
from rich.console import Console
|
|
451
|
-
from rich.syntax import Syntax
|
|
452
468
|
from rich.panel import Panel
|
|
469
|
+
from rich.syntax import Syntax
|
|
470
|
+
|
|
453
471
|
console = Console()
|
|
454
472
|
syntax = Syntax(text, "cisco", theme="monokai", line_numbers=True, word_wrap=True)
|
|
455
473
|
console.print(Panel(syntax, border_style="dim"))
|
|
@@ -506,10 +524,11 @@ def _count_components(data) -> int:
|
|
|
506
524
|
return count
|
|
507
525
|
|
|
508
526
|
|
|
509
|
-
def _print_parsed_config(data: dict, path:
|
|
527
|
+
def _print_parsed_config(data: dict, path: str | None, format: str, title: str):
|
|
510
528
|
"""Display parsed config with AttributeValue awareness."""
|
|
511
529
|
from rich.console import Console
|
|
512
530
|
from rich.table import Table
|
|
531
|
+
|
|
513
532
|
console = Console()
|
|
514
533
|
|
|
515
534
|
if format == "json":
|
|
@@ -527,7 +546,11 @@ def _print_parsed_config(data: dict, path: Optional[str], format: str, title: st
|
|
|
527
546
|
console.print(f" [dim]source: {source}[/dim]")
|
|
528
547
|
return
|
|
529
548
|
# Dict of AttributeValues (e.g. system.config)
|
|
530
|
-
if
|
|
549
|
+
if (
|
|
550
|
+
isinstance(section, dict)
|
|
551
|
+
and section
|
|
552
|
+
and all(_is_attribute_value(v) for v in section.values() if isinstance(v, dict) and v)
|
|
553
|
+
):
|
|
531
554
|
_print_attributes_table(console, section, title=path)
|
|
532
555
|
return
|
|
533
556
|
# Container with named entries (e.g. interfaces, ntp.servers)
|
|
@@ -553,12 +576,15 @@ def _print_parsed_config(data: dict, path: Optional[str], format: str, title: st
|
|
|
553
576
|
)
|
|
554
577
|
|
|
555
578
|
console.print(table)
|
|
556
|
-
console.print(
|
|
579
|
+
console.print(
|
|
580
|
+
"[dim]Use --path <section> to drill in, --format json for full dump, --render for device config[/dim]"
|
|
581
|
+
)
|
|
557
582
|
|
|
558
583
|
|
|
559
584
|
def _print_attributes_table(console, data: dict, title: str = ""):
|
|
560
585
|
"""Print a dict where values are AttributeValues as a clean table."""
|
|
561
586
|
from rich.table import Table
|
|
587
|
+
|
|
562
588
|
table = Table(title=title, show_header=True, title_style="bold", title_justify="left")
|
|
563
589
|
table.add_column("Attribute", style="cyan")
|
|
564
590
|
table.add_column("Value", style="white")
|
|
@@ -578,7 +604,6 @@ def _print_attributes_table(console, data: dict, title: str = ""):
|
|
|
578
604
|
|
|
579
605
|
def _print_config_section(console, data: dict, path: str):
|
|
580
606
|
"""Print a config section, handling containers and nested AttributeValues."""
|
|
581
|
-
from rich.table import Table
|
|
582
607
|
|
|
583
608
|
# Check if entries are named components (e.g. interfaces, ntp.servers)
|
|
584
609
|
for entry_name, entry_data in data.items():
|
|
@@ -611,6 +636,7 @@ def _print_config_section(console, data: dict, path: str):
|
|
|
611
636
|
|
|
612
637
|
# ── Util ────────────────────────────────────────────────────────
|
|
613
638
|
|
|
639
|
+
|
|
614
640
|
def _flatten_node(node, node_id: str = None) -> dict:
|
|
615
641
|
"""Flatten NodeResponse into a single-level dict for display."""
|
|
616
642
|
data = node.model_dump() if hasattr(node, "model_dump") else dict(node)
|
|
@@ -622,7 +648,8 @@ def _flatten_node(node, node_id: str = None) -> dict:
|
|
|
622
648
|
"hostname": logical_node.get("hostname"),
|
|
623
649
|
"site": logical_node.get("site"),
|
|
624
650
|
"role": logical_node.get("role"),
|
|
625
|
-
"
|
|
651
|
+
"admin_status": data.get("admin_status"),
|
|
652
|
+
"provision_status": data.get("provision_status"),
|
|
626
653
|
"vendor": asset.get("vendor"),
|
|
627
654
|
"os": asset.get("os"),
|
|
628
655
|
"os_version": asset.get("os_version"),
|
|
@@ -653,7 +680,8 @@ def _resolve_node(sdk, identifier: str):
|
|
|
653
680
|
elif len(result) > 1:
|
|
654
681
|
typer.echo(f"Multiple nodes match hostname '{identifier}':")
|
|
655
682
|
from acex_cli.output import display_list
|
|
656
|
-
|
|
683
|
+
|
|
684
|
+
display_list(result, columns=["id", "hostname", "site", "admin_status", "provision_status"], title="")
|
|
657
685
|
typer.echo("Use the ID to specify which node.")
|
|
658
686
|
raise typer.Exit(1)
|
|
659
687
|
|
|
@@ -672,7 +700,8 @@ def _resolve_node_id(sdk, identifier: str) -> str:
|
|
|
672
700
|
elif len(result) > 1:
|
|
673
701
|
typer.echo(f"Multiple nodes match hostname '{identifier}':")
|
|
674
702
|
from acex_cli.output import display_list
|
|
675
|
-
|
|
703
|
+
|
|
704
|
+
display_list(result, columns=["id", "hostname", "site", "admin_status", "provision_status"], title="")
|
|
676
705
|
typer.echo("Use the ID to specify which node.")
|
|
677
706
|
raise typer.Exit(1)
|
|
678
707
|
typer.echo(f"Node '{identifier}' not found.")
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
"""Formatters for displaying configuration diffs in CLI."""
|
|
2
|
+
|
|
2
3
|
import json
|
|
3
|
-
|
|
4
|
-
from
|
|
5
|
-
from
|
|
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 [
|
|
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(
|
|
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 [
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
321
|
-
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
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
62
|
-
exclude:
|
|
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
|
|
File without changes
|
|
File without changes
|