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.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. 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)