systemlink-cli 1.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/utils.py
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"""Shared utility functions for SystemLink CLI."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Dict, List, Any, Optional, Callable
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import keyring
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SystemLinkConfig:
|
|
15
|
+
"""Simple configuration class for SystemLink API connection."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, server_uri: str, api_key: str, ssl_verify: bool = True):
|
|
18
|
+
"""Initialize SystemLink configuration.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
server_uri: Base URL for the SystemLink API
|
|
22
|
+
api_key: API key for authentication
|
|
23
|
+
ssl_verify: Whether to verify SSL certificates
|
|
24
|
+
"""
|
|
25
|
+
self.server_uri = server_uri
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.ssl_verify = ssl_verify
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExitCodes:
|
|
31
|
+
"""Standard exit codes for CLI operations."""
|
|
32
|
+
|
|
33
|
+
SUCCESS = 0
|
|
34
|
+
GENERAL_ERROR = 1
|
|
35
|
+
INVALID_INPUT = 2
|
|
36
|
+
NOT_FOUND = 3
|
|
37
|
+
PERMISSION_DENIED = 4
|
|
38
|
+
NETWORK_ERROR = 5
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_readonly_mode(operation: str = "this operation") -> None:
|
|
42
|
+
"""Check if the active profile is in readonly mode and exit if it is.
|
|
43
|
+
|
|
44
|
+
This function should be called at the start of any mutation command
|
|
45
|
+
(create, update, delete, edit, import, upload, publish, disable) to prevent
|
|
46
|
+
modifications when the profile is in readonly mode.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
operation: Description of the operation being attempted (e.g., "delete this resource")
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
SystemExit: If the active profile is in readonly mode
|
|
53
|
+
"""
|
|
54
|
+
from .profiles import is_active_profile_readonly
|
|
55
|
+
|
|
56
|
+
if is_active_profile_readonly():
|
|
57
|
+
click.echo(f"✗ Cannot {operation}: profile is in readonly mode", err=True)
|
|
58
|
+
click.echo(
|
|
59
|
+
"Readonly mode disables all mutation operations "
|
|
60
|
+
"(create, update, delete, edit, import, upload, publish, disable) for safety.",
|
|
61
|
+
err=True,
|
|
62
|
+
)
|
|
63
|
+
sys.exit(ExitCodes.PERMISSION_DENIED)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def handle_api_error(exc: Exception) -> None:
|
|
67
|
+
"""Handle API errors with appropriate exit codes and consistent formatting.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
exc: The exception to handle
|
|
71
|
+
"""
|
|
72
|
+
error_msg = str(exc).lower()
|
|
73
|
+
if "not found" in error_msg:
|
|
74
|
+
click.echo(f"✗ Resource not found: {exc}", err=True)
|
|
75
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
76
|
+
elif "permission" in error_msg or "unauthorized" in error_msg:
|
|
77
|
+
click.echo(f"✗ Permission denied: {exc}", err=True)
|
|
78
|
+
sys.exit(ExitCodes.PERMISSION_DENIED)
|
|
79
|
+
elif "network" in error_msg or "connection" in error_msg:
|
|
80
|
+
click.echo(f"✗ Network error: {exc}", err=True)
|
|
81
|
+
sys.exit(ExitCodes.NETWORK_ERROR)
|
|
82
|
+
else:
|
|
83
|
+
click.echo(f"✗ Error: {exc}", err=True)
|
|
84
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_success(message: str, data: Optional[Any] = None) -> None:
|
|
88
|
+
"""Format success messages consistently.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
message: Success message to display
|
|
92
|
+
data: Optional data to display with the message
|
|
93
|
+
"""
|
|
94
|
+
if data:
|
|
95
|
+
click.echo(f"✓ {message}")
|
|
96
|
+
for key, value in data.items():
|
|
97
|
+
click.echo(f" {key}: {value}")
|
|
98
|
+
else:
|
|
99
|
+
click.echo(f"✓ {message}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def output_list_data(
|
|
103
|
+
items: List[Dict[str, Any]],
|
|
104
|
+
output_format: str,
|
|
105
|
+
headers: List[str],
|
|
106
|
+
table_data_func: Callable[[Dict[str, Any]], List[str]],
|
|
107
|
+
empty_message: str = "No items found.",
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Handle JSON and table output for list commands consistently.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
items: List of items to output
|
|
113
|
+
output_format: 'json' or 'table'
|
|
114
|
+
headers: List of header names for table output
|
|
115
|
+
table_data_func: Function that converts items to table rows
|
|
116
|
+
empty_message: Message to display when no items are found
|
|
117
|
+
"""
|
|
118
|
+
if not items:
|
|
119
|
+
if output_format.lower() == "json":
|
|
120
|
+
click.echo("[]")
|
|
121
|
+
else:
|
|
122
|
+
click.echo(empty_message)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if output_format.lower() == "json":
|
|
126
|
+
click.echo(json.dumps(items, indent=2))
|
|
127
|
+
else:
|
|
128
|
+
from tabulate import tabulate
|
|
129
|
+
from click import style as cstyle
|
|
130
|
+
|
|
131
|
+
def color_row(row: List[str]) -> List[str]:
|
|
132
|
+
"""Color table rows with consistent styling."""
|
|
133
|
+
ws = str(row[0])
|
|
134
|
+
ws_short = ws[:15] + ("…" if len(ws) > 15 else "")
|
|
135
|
+
return [
|
|
136
|
+
cstyle(ws_short, fg="blue"),
|
|
137
|
+
cstyle(str(row[1]), fg="green"),
|
|
138
|
+
cstyle(str(row[2]), fg="blue"),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
table = []
|
|
142
|
+
for item in items:
|
|
143
|
+
table.append(color_row(table_data_func(item)))
|
|
144
|
+
|
|
145
|
+
styled_headers = [
|
|
146
|
+
cstyle(headers[0], fg="blue", bold=True),
|
|
147
|
+
cstyle(headers[1], fg="green", bold=True),
|
|
148
|
+
cstyle(headers[2], fg="blue", bold=True),
|
|
149
|
+
]
|
|
150
|
+
click.echo(tabulate(table, headers=styled_headers, tablefmt="github"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def output_formatted_list(
|
|
154
|
+
items: List[Dict[str, Any]],
|
|
155
|
+
output_format: str,
|
|
156
|
+
headers: List[str],
|
|
157
|
+
column_widths: List[int],
|
|
158
|
+
row_formatter_func: Callable[[Dict[str, Any]], List[Any]],
|
|
159
|
+
empty_message: str = "No items found.",
|
|
160
|
+
total_label: str = "item(s)",
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Handle JSON and table output with box-drawing characters for list commands.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
items: List of items to output
|
|
166
|
+
output_format: 'json' or 'table'
|
|
167
|
+
headers: List of header names for table output
|
|
168
|
+
column_widths: List of column widths for table formatting
|
|
169
|
+
row_formatter_func: Function that converts item to list of column values
|
|
170
|
+
empty_message: Message to display when no items are found
|
|
171
|
+
total_label: Label for total count (e.g., "configuration(s)", "template(s)")
|
|
172
|
+
"""
|
|
173
|
+
if not items:
|
|
174
|
+
if output_format.lower() == "json":
|
|
175
|
+
click.echo("[]")
|
|
176
|
+
else:
|
|
177
|
+
click.echo(empty_message)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if output_format.lower() == "json":
|
|
181
|
+
click.echo(json.dumps(items, indent=2))
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# Table format with box-drawing characters
|
|
185
|
+
if len(headers) != len(column_widths):
|
|
186
|
+
raise ValueError("Headers and column_widths must have the same length")
|
|
187
|
+
|
|
188
|
+
# Top border
|
|
189
|
+
border_chars = ["┌"] + [("─" * (w + 2)) for w in column_widths]
|
|
190
|
+
border_line = border_chars[0] + border_chars[1]
|
|
191
|
+
for part in border_chars[2:]:
|
|
192
|
+
border_line += "┬" + part
|
|
193
|
+
border_line += "┐"
|
|
194
|
+
click.echo(border_line)
|
|
195
|
+
|
|
196
|
+
# Header row
|
|
197
|
+
header_parts = ["│"]
|
|
198
|
+
for header, width in zip(headers, column_widths):
|
|
199
|
+
header_parts.append(f" {header:<{width}} │")
|
|
200
|
+
click.echo("".join(header_parts))
|
|
201
|
+
|
|
202
|
+
# Middle border
|
|
203
|
+
border_chars = ["├"] + [("─" * (w + 2)) for w in column_widths]
|
|
204
|
+
border_line = border_chars[0] + border_chars[1]
|
|
205
|
+
for part in border_chars[2:]:
|
|
206
|
+
border_line += "┼" + part
|
|
207
|
+
border_line += "┤"
|
|
208
|
+
click.echo(border_line)
|
|
209
|
+
|
|
210
|
+
# Data rows
|
|
211
|
+
for item in items:
|
|
212
|
+
row_data = row_formatter_func(item)
|
|
213
|
+
if len(row_data) != len(column_widths):
|
|
214
|
+
raise ValueError("Row data must match column count")
|
|
215
|
+
|
|
216
|
+
row_parts = ["│"]
|
|
217
|
+
for value, width in zip(row_data, column_widths):
|
|
218
|
+
# Truncate if necessary
|
|
219
|
+
str_value = str(value or "")[:width]
|
|
220
|
+
row_parts.append(f" {str_value:<{width}} │")
|
|
221
|
+
click.echo("".join(row_parts))
|
|
222
|
+
|
|
223
|
+
# Bottom border
|
|
224
|
+
border_chars = ["└"] + [("─" * (w + 2)) for w in column_widths]
|
|
225
|
+
border_line = border_chars[0] + border_chars[1]
|
|
226
|
+
for part in border_chars[2:]:
|
|
227
|
+
border_line += "┴" + part
|
|
228
|
+
border_line += "┘"
|
|
229
|
+
click.echo(border_line)
|
|
230
|
+
|
|
231
|
+
# Total count
|
|
232
|
+
click.echo(f"\nTotal: {len(items)} {total_label}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def resolve_workspace_filter(workspace: str, workspace_map: Dict[str, str]) -> str:
|
|
236
|
+
"""Resolve workspace name to ID for filtering.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
workspace: Workspace name or ID to resolve
|
|
240
|
+
workspace_map: Dictionary mapping workspace IDs to names
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Workspace ID (either the original if it was an ID, or resolved from name)
|
|
244
|
+
"""
|
|
245
|
+
if not workspace:
|
|
246
|
+
return workspace
|
|
247
|
+
|
|
248
|
+
# Check if it's already an ID (exists as key in workspace_map)
|
|
249
|
+
if workspace in workspace_map:
|
|
250
|
+
return workspace
|
|
251
|
+
|
|
252
|
+
# Try to find by name (case-insensitive)
|
|
253
|
+
for ws_id, ws_name in workspace_map.items():
|
|
254
|
+
if ws_name and workspace.lower() == ws_name.lower():
|
|
255
|
+
return ws_id
|
|
256
|
+
|
|
257
|
+
# Return original if no match found
|
|
258
|
+
return workspace
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def filter_by_workspace(
|
|
262
|
+
items: List[Dict[str, Any]],
|
|
263
|
+
workspace: str,
|
|
264
|
+
workspace_map: Dict[str, str],
|
|
265
|
+
workspace_field: str = "workspace",
|
|
266
|
+
) -> List[Dict[str, Any]]:
|
|
267
|
+
"""Filter items by workspace name or ID.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
items: List of items to filter
|
|
271
|
+
workspace: Workspace name or ID to filter by
|
|
272
|
+
workspace_map: Dictionary mapping workspace IDs to names
|
|
273
|
+
workspace_field: Field name in items that contains workspace ID
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Filtered list of items
|
|
277
|
+
"""
|
|
278
|
+
if not workspace:
|
|
279
|
+
return items
|
|
280
|
+
|
|
281
|
+
filtered_items = []
|
|
282
|
+
for item in items:
|
|
283
|
+
item_workspace = item.get(workspace_field, "")
|
|
284
|
+
item_workspace_name = workspace_map.get(item_workspace, item_workspace)
|
|
285
|
+
|
|
286
|
+
# Match by ID or name (case-insensitive)
|
|
287
|
+
if workspace.lower() == item_workspace.lower() or (
|
|
288
|
+
item_workspace_name and workspace.lower() == item_workspace_name.lower()
|
|
289
|
+
):
|
|
290
|
+
filtered_items.append(item)
|
|
291
|
+
|
|
292
|
+
return filtered_items
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --- SystemLink HTTP Configuration ---
|
|
296
|
+
def get_http_configuration() -> SystemLinkConfig:
|
|
297
|
+
"""Return a configured SystemLink configuration using profiles, environment, or keyring.
|
|
298
|
+
|
|
299
|
+
Preference order:
|
|
300
|
+
1. Environment variables (SYSTEMLINK_API_URL, SYSTEMLINK_API_KEY)
|
|
301
|
+
2. Active profile from config file
|
|
302
|
+
3. Keyring (legacy fallback)
|
|
303
|
+
"""
|
|
304
|
+
server_uri = get_base_url()
|
|
305
|
+
api_key = get_api_key()
|
|
306
|
+
|
|
307
|
+
ssl_verify = get_ssl_verify()
|
|
308
|
+
|
|
309
|
+
return SystemLinkConfig(
|
|
310
|
+
server_uri=server_uri,
|
|
311
|
+
api_key=api_key,
|
|
312
|
+
ssl_verify=ssl_verify,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def get_base_url() -> str:
|
|
317
|
+
"""Retrieve the SystemLink API base URL.
|
|
318
|
+
|
|
319
|
+
Preference order:
|
|
320
|
+
1. Environment variable SYSTEMLINK_API_URL (for runtime overrides/testing)
|
|
321
|
+
2. Active profile from config file
|
|
322
|
+
3. Combined keyring config (legacy)
|
|
323
|
+
4. Legacy keyring entry SYSTEMLINK_API_URL
|
|
324
|
+
5. Default fallback to localhost
|
|
325
|
+
"""
|
|
326
|
+
# First, check environment variable (highest priority for runtime overrides)
|
|
327
|
+
url = os.environ.get("SYSTEMLINK_API_URL")
|
|
328
|
+
if url:
|
|
329
|
+
return url
|
|
330
|
+
|
|
331
|
+
# Second, try the active profile
|
|
332
|
+
try:
|
|
333
|
+
from .profiles import get_active_profile
|
|
334
|
+
|
|
335
|
+
profile = get_active_profile()
|
|
336
|
+
if profile and profile.server:
|
|
337
|
+
return profile.server
|
|
338
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError, AttributeError):
|
|
339
|
+
# Profile file missing, corrupted, or malformed - fall back to keyring
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
# Third, try the combined keyring config (legacy)
|
|
343
|
+
cfg = _get_keyring_config()
|
|
344
|
+
if cfg and isinstance(cfg, dict):
|
|
345
|
+
config_url = cfg.get("api_url")
|
|
346
|
+
if config_url:
|
|
347
|
+
return config_url
|
|
348
|
+
|
|
349
|
+
# Fourth, try legacy keyring entry
|
|
350
|
+
url = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_URL")
|
|
351
|
+
if url:
|
|
352
|
+
return url
|
|
353
|
+
|
|
354
|
+
return "http://localhost:8000"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_web_url() -> str:
|
|
358
|
+
"""Return the SystemLink primary web UI URL.
|
|
359
|
+
|
|
360
|
+
Preference order:
|
|
361
|
+
1. Environment variable SYSTEMLINK_WEB_URL
|
|
362
|
+
2. Active profile from config file
|
|
363
|
+
3. Combined keyring config (legacy)
|
|
364
|
+
4. Legacy keyring entry SYSTEMLINK_WEB_URL
|
|
365
|
+
5. Derived from get_base_url()
|
|
366
|
+
"""
|
|
367
|
+
# 1) Explicit override via environment variable
|
|
368
|
+
url = os.environ.get("SYSTEMLINK_WEB_URL")
|
|
369
|
+
if url:
|
|
370
|
+
return url.rstrip("/")
|
|
371
|
+
|
|
372
|
+
# 2) Try the active profile
|
|
373
|
+
try:
|
|
374
|
+
from .profiles import get_active_profile
|
|
375
|
+
|
|
376
|
+
profile = get_active_profile()
|
|
377
|
+
if profile and profile.web_url:
|
|
378
|
+
return profile.web_url.rstrip("/")
|
|
379
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError, AttributeError):
|
|
380
|
+
# Profile unavailable or misconfigured, fall back to other sources
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
# 3) Combined keyring config entry (legacy)
|
|
384
|
+
cfg = _get_keyring_config()
|
|
385
|
+
if cfg and isinstance(cfg, dict):
|
|
386
|
+
maybe = cfg.get("web_url") or cfg.get("webUrl") or cfg.get("web_ui_url")
|
|
387
|
+
if maybe:
|
|
388
|
+
return str(maybe).rstrip("/")
|
|
389
|
+
|
|
390
|
+
# 4) Legacy keyring entry fallback
|
|
391
|
+
url = keyring.get_password("systemlink-cli", "SYSTEMLINK_WEB_URL")
|
|
392
|
+
if url:
|
|
393
|
+
return url.rstrip("/")
|
|
394
|
+
|
|
395
|
+
# Derive from API base URL
|
|
396
|
+
base = get_base_url()
|
|
397
|
+
try:
|
|
398
|
+
from urllib.parse import urlparse
|
|
399
|
+
|
|
400
|
+
parsed = urlparse(base if base.startswith("http") else "https://" + base)
|
|
401
|
+
host = parsed.netloc or parsed.path
|
|
402
|
+
if not host:
|
|
403
|
+
return "https://localhost"
|
|
404
|
+
return f"https://{host.rstrip('/')}"
|
|
405
|
+
except Exception:
|
|
406
|
+
return "https://localhost"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _get_keyring_config() -> Dict[str, Any]:
|
|
410
|
+
"""Attempt to read a single JSON config entry from keyring.
|
|
411
|
+
|
|
412
|
+
This allows storing a combined config (api_url, api_key, web_url) under
|
|
413
|
+
one key (e.g. SERVICE='systemlink-cli', key='SYSTEMLINK_CONFIG'). The
|
|
414
|
+
function returns a dict on success or an empty dict on failure.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
cfg_text = keyring.get_password("systemlink-cli", "SYSTEMLINK_CONFIG")
|
|
418
|
+
if not cfg_text:
|
|
419
|
+
return {}
|
|
420
|
+
import json
|
|
421
|
+
|
|
422
|
+
parsed = json.loads(cfg_text)
|
|
423
|
+
if isinstance(parsed, dict):
|
|
424
|
+
return parsed
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
return {}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def get_api_key() -> str:
|
|
431
|
+
"""Retrieve the SystemLink API key.
|
|
432
|
+
|
|
433
|
+
Preference order:
|
|
434
|
+
1. Environment variable SYSTEMLINK_API_KEY (for runtime overrides/testing)
|
|
435
|
+
2. Active profile from config file
|
|
436
|
+
3. Combined keyring config (legacy)
|
|
437
|
+
4. Legacy keyring entry SYSTEMLINK_API_KEY
|
|
438
|
+
"""
|
|
439
|
+
import click
|
|
440
|
+
|
|
441
|
+
# First, check environment variable (highest priority for runtime overrides)
|
|
442
|
+
api_key = os.environ.get("SYSTEMLINK_API_KEY")
|
|
443
|
+
if api_key:
|
|
444
|
+
return api_key
|
|
445
|
+
|
|
446
|
+
# Second, try the active profile
|
|
447
|
+
try:
|
|
448
|
+
from .profiles import get_active_profile
|
|
449
|
+
|
|
450
|
+
profile = get_active_profile()
|
|
451
|
+
if profile and profile.api_key:
|
|
452
|
+
return profile.api_key
|
|
453
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError, AttributeError):
|
|
454
|
+
# Profile lookup error, fall back to keyring-based configuration
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
# Third, consult combined keyring config if present (legacy)
|
|
458
|
+
cfg = _get_keyring_config()
|
|
459
|
+
if cfg and isinstance(cfg, dict):
|
|
460
|
+
maybe = cfg.get("api_key") or cfg.get("apiKey") or cfg.get("apiToken")
|
|
461
|
+
if maybe:
|
|
462
|
+
return str(maybe)
|
|
463
|
+
|
|
464
|
+
# Fourth, try legacy keyring entry
|
|
465
|
+
api_key = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_KEY")
|
|
466
|
+
if api_key:
|
|
467
|
+
return api_key
|
|
468
|
+
|
|
469
|
+
click.echo(
|
|
470
|
+
"Error: API key not found. Please set the SYSTEMLINK_API_KEY "
|
|
471
|
+
"environment variable or run 'slcli login --profile <name>'."
|
|
472
|
+
)
|
|
473
|
+
raise click.ClickException("API key not found.")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def get_headers(content_type: str = "") -> Dict[str, str]:
|
|
477
|
+
"""Return headers for SystemLink API requests.
|
|
478
|
+
|
|
479
|
+
Allows caller to override Content-Type. If content_type is None or empty, omit the header.
|
|
480
|
+
"""
|
|
481
|
+
headers = {
|
|
482
|
+
"x-ni-api-key": get_api_key(),
|
|
483
|
+
"User-Agent": "SystemLink-CLI/1.0 (cross-platform)",
|
|
484
|
+
}
|
|
485
|
+
if content_type:
|
|
486
|
+
headers["Content-Type"] = content_type
|
|
487
|
+
return headers
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def get_ssl_verify() -> bool:
|
|
491
|
+
"""Return SSL verification setting from environment variable. Defaults to True."""
|
|
492
|
+
env = os.environ.get("SLCLI_SSL_VERIFY")
|
|
493
|
+
if env is not None:
|
|
494
|
+
return env.lower() not in ("0", "false", "no")
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def get_workspace_id_by_name(name: str) -> str:
|
|
499
|
+
"""Return the workspace id for a given workspace name (case-sensitive). Raises if not found."""
|
|
500
|
+
ws_map = get_workspace_map()
|
|
501
|
+
for ws_id, ws_name in ws_map.items():
|
|
502
|
+
if ws_name == name:
|
|
503
|
+
return ws_id
|
|
504
|
+
raise ValueError(f"Workspace name '{name}' not found.")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def get_workspace_map() -> Dict[str, str]:
|
|
508
|
+
"""Get a mapping of workspace IDs to names.
|
|
509
|
+
|
|
510
|
+
Fetches all workspaces using pagination (max 100 per request).
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
Dictionary mapping workspace ID to workspace name
|
|
514
|
+
"""
|
|
515
|
+
try:
|
|
516
|
+
workspace_map: Dict[str, str] = {}
|
|
517
|
+
skip = 0
|
|
518
|
+
page_size = 100 # API max take is 100
|
|
519
|
+
|
|
520
|
+
while True:
|
|
521
|
+
url = f"{get_base_url()}/niuser/v1/workspaces?take={page_size}&skip={skip}"
|
|
522
|
+
resp = make_api_request("GET", url, payload=None, handle_errors=False)
|
|
523
|
+
data = resp.json()
|
|
524
|
+
workspaces = data.get("workspaces", [])
|
|
525
|
+
|
|
526
|
+
# Add workspaces from this page to the map
|
|
527
|
+
for ws in workspaces:
|
|
528
|
+
if ws.get("id"):
|
|
529
|
+
workspace_map[ws.get("id")] = ws.get("name")
|
|
530
|
+
|
|
531
|
+
# Check if we got all workspaces
|
|
532
|
+
total_count = data.get("totalCount", 0)
|
|
533
|
+
if skip + len(workspaces) >= total_count:
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
skip += page_size
|
|
537
|
+
|
|
538
|
+
return workspace_map
|
|
539
|
+
except Exception:
|
|
540
|
+
return {}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# --- File I/O Utilities ---
|
|
544
|
+
def load_json_file(filepath: str) -> Any:
|
|
545
|
+
"""Load and parse JSON file with consistent error handling.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
filepath: Path to JSON file to load
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
Parsed JSON data (dict or list or any JSON value)
|
|
552
|
+
|
|
553
|
+
Raises:
|
|
554
|
+
click.ClickException: If file cannot be loaded or parsed
|
|
555
|
+
"""
|
|
556
|
+
try:
|
|
557
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
558
|
+
return json.load(f)
|
|
559
|
+
except FileNotFoundError:
|
|
560
|
+
click.echo(f"✗ File not found: {filepath}", err=True)
|
|
561
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
562
|
+
except json.JSONDecodeError as exc:
|
|
563
|
+
click.echo(f"✗ Invalid JSON in file {filepath}: {exc}", err=True)
|
|
564
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
565
|
+
except Exception as exc:
|
|
566
|
+
click.echo(f"✗ Error reading file {filepath}: {exc}", err=True)
|
|
567
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def save_json_file(
|
|
571
|
+
data: Any, filepath: str, custom_serializer: Optional[Callable[[Any], Any]] = None
|
|
572
|
+
) -> None:
|
|
573
|
+
"""Save data to JSON file with consistent formatting and error handling.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
data: Data to save as JSON
|
|
577
|
+
filepath: Path where to save the JSON file
|
|
578
|
+
custom_serializer: Optional custom JSON serializer function
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
def _default_json_serializer(obj: Any) -> Any:
|
|
582
|
+
"""Default JSON serializer for common types."""
|
|
583
|
+
if isinstance(obj, (datetime.datetime, datetime.date)):
|
|
584
|
+
return obj.isoformat()
|
|
585
|
+
return str(obj)
|
|
586
|
+
|
|
587
|
+
serializer = custom_serializer or _default_json_serializer
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
591
|
+
json.dump(data, f, indent=2, default=serializer)
|
|
592
|
+
except Exception as exc:
|
|
593
|
+
click.echo(f"✗ Error writing file {filepath}: {exc}", err=True)
|
|
594
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# --- API Request Utilities ---
|
|
598
|
+
def make_api_request(
|
|
599
|
+
method: str,
|
|
600
|
+
url: str,
|
|
601
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
602
|
+
headers: Optional[Dict[str, str]] = None,
|
|
603
|
+
handle_errors: bool = True,
|
|
604
|
+
files: Optional[Dict[str, Any]] = None,
|
|
605
|
+
data: Optional[Dict[str, Any]] = None,
|
|
606
|
+
stream: bool = False,
|
|
607
|
+
) -> requests.Response:
|
|
608
|
+
"""Make API request with consistent error handling and configuration.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
method: HTTP method (GET, POST, etc.)
|
|
612
|
+
url: API endpoint URL
|
|
613
|
+
payload: Request payload for POST/PUT requests (JSON body)
|
|
614
|
+
headers: Additional headers (will be merged with default headers)
|
|
615
|
+
handle_errors: Whether to handle errors with consistent formatting
|
|
616
|
+
files: Files to upload (for multipart form data)
|
|
617
|
+
data: Form data (for multipart requests, used with files)
|
|
618
|
+
stream: Whether to stream the response (for large file downloads)
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
Response object
|
|
622
|
+
|
|
623
|
+
Raises:
|
|
624
|
+
Handled via handle_api_error() if handle_errors=True
|
|
625
|
+
"""
|
|
626
|
+
try:
|
|
627
|
+
# Merge provided headers with default headers
|
|
628
|
+
default_headers = get_headers()
|
|
629
|
+
if headers:
|
|
630
|
+
default_headers.update(headers)
|
|
631
|
+
|
|
632
|
+
# For multipart file uploads, remove Content-Type to let requests set it
|
|
633
|
+
if files:
|
|
634
|
+
default_headers.pop("Content-Type", None)
|
|
635
|
+
|
|
636
|
+
ssl_verify = get_ssl_verify()
|
|
637
|
+
|
|
638
|
+
if method.upper() == "GET":
|
|
639
|
+
resp = requests.get(url, headers=default_headers, verify=ssl_verify, stream=stream)
|
|
640
|
+
elif method.upper() == "POST":
|
|
641
|
+
if files:
|
|
642
|
+
# Multipart file upload
|
|
643
|
+
resp = requests.post(
|
|
644
|
+
url, headers=default_headers, files=files, data=data, verify=ssl_verify
|
|
645
|
+
)
|
|
646
|
+
else:
|
|
647
|
+
resp = requests.post(url, headers=default_headers, json=payload, verify=ssl_verify)
|
|
648
|
+
elif method.upper() == "PUT":
|
|
649
|
+
resp = requests.put(url, headers=default_headers, json=payload, verify=ssl_verify)
|
|
650
|
+
elif method.upper() == "PATCH":
|
|
651
|
+
resp = requests.patch(url, headers=default_headers, json=payload, verify=ssl_verify)
|
|
652
|
+
elif method.upper() == "DELETE":
|
|
653
|
+
resp = requests.delete(url, headers=default_headers, verify=ssl_verify)
|
|
654
|
+
else:
|
|
655
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
656
|
+
|
|
657
|
+
resp.raise_for_status()
|
|
658
|
+
return resp
|
|
659
|
+
|
|
660
|
+
except requests.RequestException as exc:
|
|
661
|
+
if handle_errors:
|
|
662
|
+
handle_api_error(exc)
|
|
663
|
+
# This line is never reached due to sys.exit() in handle_api_error(),
|
|
664
|
+
# but is needed for type checking
|
|
665
|
+
return None # type: ignore
|
|
666
|
+
else:
|
|
667
|
+
raise
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# --- Workspace Validation Utilities ---
|
|
671
|
+
def get_workspace_id_with_fallback(workspace_name: str) -> str:
|
|
672
|
+
"""Get workspace ID by name with fallback to original name if not found.
|
|
673
|
+
|
|
674
|
+
This is a common pattern used across commands where workspace parameter
|
|
675
|
+
can be either a name or an ID.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
workspace_name: Workspace name or ID
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
Workspace ID (validated name converted to ID, or original value as fallback)
|
|
682
|
+
"""
|
|
683
|
+
try:
|
|
684
|
+
return get_workspace_id_by_name(workspace_name)
|
|
685
|
+
except (ValueError, Exception):
|
|
686
|
+
# If workspace name lookup fails, use the original value as-is
|
|
687
|
+
# This allows for direct workspace ID usage
|
|
688
|
+
return workspace_name
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def validate_workspace_access(workspace_name: str, warn_on_error: bool = True) -> str:
|
|
692
|
+
"""Validate workspace access with optional warning on failure.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
workspace_name: Workspace name to validate
|
|
696
|
+
warn_on_error: Whether to show warning if workspace not found
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
Workspace ID if found, original name if not found
|
|
700
|
+
"""
|
|
701
|
+
try:
|
|
702
|
+
ws_id = get_workspace_id_by_name(workspace_name)
|
|
703
|
+
if not isinstance(ws_id, str):
|
|
704
|
+
raise ValueError("Workspace ID must be a string.")
|
|
705
|
+
return ws_id
|
|
706
|
+
except Exception:
|
|
707
|
+
if warn_on_error:
|
|
708
|
+
click.echo(
|
|
709
|
+
f"✗ Warning: Workspace '{workspace_name}' not found, using as-is.",
|
|
710
|
+
err=True,
|
|
711
|
+
)
|
|
712
|
+
return workspace_name
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def sanitize_filename(name: str, fallback_prefix: str = "file") -> str:
|
|
716
|
+
"""Sanitize a name to create a safe filename.
|
|
717
|
+
|
|
718
|
+
Removes invalid characters, converts spaces to hyphens, and makes lowercase.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
name: The original name to sanitize
|
|
722
|
+
fallback_prefix: Prefix to use if name is empty after sanitization
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
A safe filename string
|
|
726
|
+
"""
|
|
727
|
+
if not name:
|
|
728
|
+
return fallback_prefix
|
|
729
|
+
|
|
730
|
+
# Keep only alphanumeric characters, spaces, hyphens, and underscores
|
|
731
|
+
safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "-", "_")).rstrip()
|
|
732
|
+
|
|
733
|
+
# Replace spaces with hyphens and convert to lowercase
|
|
734
|
+
safe_name = safe_name.replace(" ", "-").lower()
|
|
735
|
+
|
|
736
|
+
# Remove multiple consecutive hyphens
|
|
737
|
+
import re
|
|
738
|
+
|
|
739
|
+
safe_name = re.sub(r"-+", "-", safe_name)
|
|
740
|
+
|
|
741
|
+
# Remove leading/trailing hyphens
|
|
742
|
+
safe_name = safe_name.strip("-")
|
|
743
|
+
|
|
744
|
+
# Return fallback if name becomes empty
|
|
745
|
+
return safe_name if safe_name else fallback_prefix
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def extract_error_type(error_name: str) -> str:
|
|
749
|
+
"""Extract a readable error type from a full class name.
|
|
750
|
+
|
|
751
|
+
Args:
|
|
752
|
+
error_name: Full error class name (e.g., "Skyline.WorkOrder.WorkflowNotFoundOrNoAccess")
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
Short error type (e.g., "WorkflowNotFoundOrNoAccess")
|
|
756
|
+
"""
|
|
757
|
+
if not error_name:
|
|
758
|
+
return ""
|
|
759
|
+
return error_name.split(".")[-1] if "." in error_name else error_name
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def parse_inner_errors(inner_errors: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
763
|
+
"""Parse inner errors from API response into a standardized format.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
inner_errors: List of inner error objects from API response
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
List of parsed error dictionaries with standardized keys
|
|
770
|
+
"""
|
|
771
|
+
parsed_errors = []
|
|
772
|
+
for inner_error in inner_errors:
|
|
773
|
+
error_name = inner_error.get("name", "")
|
|
774
|
+
error_message = inner_error.get("message", "Unknown error")
|
|
775
|
+
resource_id = inner_error.get("resourceId", "")
|
|
776
|
+
resource_type = inner_error.get("resourceType", "")
|
|
777
|
+
|
|
778
|
+
parsed_errors.append(
|
|
779
|
+
{
|
|
780
|
+
"name": error_name,
|
|
781
|
+
"type": extract_error_type(error_name),
|
|
782
|
+
"message": error_message,
|
|
783
|
+
"resource_id": resource_id,
|
|
784
|
+
"resource_type": resource_type,
|
|
785
|
+
}
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
return parsed_errors
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def display_api_errors(
|
|
792
|
+
operation_name: str, response_data: Dict[str, Any], detailed: bool = True
|
|
793
|
+
) -> None:
|
|
794
|
+
"""Display API errors in a consistent format.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
operation_name: Name of the operation that failed
|
|
798
|
+
response_data: API response data containing error information
|
|
799
|
+
detailed: Whether to show detailed inner errors
|
|
800
|
+
"""
|
|
801
|
+
import sys
|
|
802
|
+
|
|
803
|
+
click.echo(f"✗ {operation_name}:", err=True)
|
|
804
|
+
|
|
805
|
+
# Check for error structure
|
|
806
|
+
error = response_data.get("error", {})
|
|
807
|
+
if not error:
|
|
808
|
+
# Fallback to simple message if no detailed error structure
|
|
809
|
+
message = response_data.get("message", "Unknown error")
|
|
810
|
+
click.echo(f" {message}", err=True)
|
|
811
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
812
|
+
|
|
813
|
+
# Display main error message
|
|
814
|
+
main_message = error.get("message", "Unknown error")
|
|
815
|
+
click.echo(f" {main_message}", err=True)
|
|
816
|
+
|
|
817
|
+
# Parse inner errors for detailed validation messages
|
|
818
|
+
if detailed:
|
|
819
|
+
inner_errors = error.get("innerErrors", [])
|
|
820
|
+
if inner_errors:
|
|
821
|
+
click.echo(" Detailed errors:", err=True)
|
|
822
|
+
parsed_errors = parse_inner_errors(inner_errors)
|
|
823
|
+
for parsed_error in parsed_errors:
|
|
824
|
+
error_type = parsed_error["type"]
|
|
825
|
+
error_message = parsed_error["message"]
|
|
826
|
+
|
|
827
|
+
if error_type:
|
|
828
|
+
click.echo(f" - {error_type}: {error_message}", err=True)
|
|
829
|
+
else:
|
|
830
|
+
click.echo(f" - {error_message}", err=True)
|
|
831
|
+
|
|
832
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|