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/cli_utils.py ADDED
@@ -0,0 +1,504 @@
1
+ """Common utility functions for all CLI commands."""
2
+
3
+ import sys
4
+ from typing import Any, Dict, List, Optional, Callable
5
+
6
+ import click
7
+ import questionary
8
+ import requests
9
+
10
+ from .utils import ExitCodes, handle_api_error
11
+
12
+
13
+ def resolve_resource_by_name_or_id(
14
+ session: requests.Session,
15
+ base_url: str,
16
+ resource_type: str,
17
+ identifier: str,
18
+ name_field: str = "name",
19
+ ) -> Optional[Dict[str, Any]]:
20
+ """Resolve a resource by name or ID.
21
+
22
+ Args:
23
+ session: Authenticated requests session
24
+ base_url: Base API URL for the resource
25
+ resource_type: Type of resource (for error messages)
26
+ identifier: Name or ID to search for
27
+ name_field: Field name to search by (default: "name")
28
+
29
+ Returns:
30
+ Resource data if found, None otherwise
31
+ """
32
+ try:
33
+ # First try as direct ID lookup
34
+ resp = session.get(f"{base_url}/{identifier}")
35
+ if resp.status_code == 200:
36
+ return resp.json()
37
+
38
+ # If not found by ID, try name-based search
39
+ resp = session.get(base_url)
40
+ if resp.status_code == 200:
41
+ data = resp.json()
42
+ resources = data if isinstance(data, list) else data.get(resource_type, [])
43
+
44
+ # Search by name
45
+ for resource in resources:
46
+ if resource.get(name_field, "").lower() == identifier.lower():
47
+ return resource
48
+
49
+ # Resource not found
50
+ click.echo(f"✗ {resource_type.title()} '{identifier}' not found", err=True)
51
+ return None
52
+
53
+ except Exception as exc:
54
+ handle_api_error(exc)
55
+ return None
56
+
57
+
58
+ def confirm_bulk_operation(
59
+ operation: str,
60
+ resource_type: str,
61
+ count: int,
62
+ force: bool = False,
63
+ ) -> bool:
64
+ """Confirm bulk operations with user prompt.
65
+
66
+ Args:
67
+ operation: Operation name (e.g., "delete", "update")
68
+ resource_type: Type of resource
69
+ count: Number of resources affected
70
+ force: Skip confirmation if True
71
+
72
+ Returns:
73
+ True if confirmed, False otherwise
74
+ """
75
+ if force:
76
+ return True
77
+
78
+ if count == 0:
79
+ click.echo(f"No {resource_type}s to {operation}")
80
+ return False
81
+
82
+ if count == 1:
83
+ return click.confirm(f"Are you sure you want to {operation} this {resource_type}?")
84
+
85
+ return click.confirm(f"Are you sure you want to {operation} {count} {resource_type}s?")
86
+
87
+
88
+ def validate_output_format(format_output: str) -> str:
89
+ """Validate and normalize output format.
90
+
91
+ Args:
92
+ format_output: User-provided format string
93
+
94
+ Returns:
95
+ Normalized format string
96
+ """
97
+ valid_formats = ["table", "json"]
98
+ normalized = format_output.lower().strip()
99
+
100
+ if normalized not in valid_formats:
101
+ click.echo(
102
+ f"✗ Invalid format '{format_output}'. Valid options: {', '.join(valid_formats)}",
103
+ err=True,
104
+ )
105
+ sys.exit(ExitCodes.INVALID_INPUT)
106
+
107
+ return normalized
108
+
109
+
110
+ def handle_pagination(
111
+ session: requests.Session,
112
+ url: str,
113
+ page_size: int = 100,
114
+ max_items: Optional[int] = None,
115
+ ) -> List[Dict[str, Any]]:
116
+ """Handle paginated API responses.
117
+
118
+ Args:
119
+ session: Authenticated requests session
120
+ url: API endpoint URL
121
+ page_size: Items per page
122
+ max_items: Maximum items to retrieve (None for all)
123
+
124
+ Returns:
125
+ List of all retrieved items
126
+ """
127
+ all_items: List[Dict[str, Any]] = []
128
+ page = 0
129
+
130
+ while True:
131
+ try:
132
+ params = {"take": page_size, "skip": page * page_size}
133
+ resp = session.get(url, params=params)
134
+ resp.raise_for_status()
135
+
136
+ data = resp.json()
137
+ items = data if isinstance(data, list) else data.get("items", [])
138
+
139
+ if not items:
140
+ break
141
+
142
+ all_items.extend(items)
143
+
144
+ # Check if we've reached max_items limit
145
+ if max_items and len(all_items) >= max_items:
146
+ all_items = all_items[:max_items]
147
+ break
148
+
149
+ # Check if we've reached the end
150
+ if len(items) < page_size:
151
+ break
152
+
153
+ page += 1
154
+
155
+ except Exception as exc:
156
+ handle_api_error(exc)
157
+ break
158
+
159
+ return all_items
160
+
161
+
162
+ def extract_id_from_response(response: requests.Response) -> Optional[str]:
163
+ """Extract ID from API response.
164
+
165
+ Args:
166
+ response: API response
167
+
168
+ Returns:
169
+ Extracted ID if found, None otherwise
170
+ """
171
+ try:
172
+ data = response.json()
173
+
174
+ # Common ID field names
175
+ id_fields = ["id", "resourceId", "workspaceId", "templateId", "userId"]
176
+
177
+ for field in id_fields:
178
+ if field in data:
179
+ return str(data[field])
180
+
181
+ # If direct ID not found, try nested structures
182
+ if "metadata" in data and "id" in data["metadata"]:
183
+ return str(data["metadata"]["id"])
184
+
185
+ return None
186
+
187
+ except (ValueError, TypeError, KeyError):
188
+ return None
189
+
190
+
191
+ def build_query_params(
192
+ filters: Optional[Dict[str, Any]] = None,
193
+ sort_by: Optional[str] = None,
194
+ sort_order: str = "asc",
195
+ page_size: Optional[int] = None,
196
+ page: Optional[int] = None,
197
+ ) -> Dict[str, Any]:
198
+ """Build standardized query parameters.
199
+
200
+ Args:
201
+ filters: Filter dictionary
202
+ sort_by: Field to sort by
203
+ sort_order: Sort order ("asc" or "desc")
204
+ page_size: Number of items per page
205
+ page: Page number (0-based)
206
+
207
+ Returns:
208
+ Query parameters dictionary
209
+ """
210
+ params = {}
211
+
212
+ if filters:
213
+ for key, value in filters.items():
214
+ if value is not None:
215
+ params[key] = value
216
+
217
+ if sort_by:
218
+ params["sortBy"] = sort_by
219
+ params["sortOrder"] = sort_order
220
+
221
+ if page_size is not None:
222
+ params["take"] = page_size
223
+
224
+ if page is not None:
225
+ params["skip"] = page * (page_size or 100)
226
+
227
+ return params
228
+
229
+
230
+ def format_error_message(
231
+ operation: str,
232
+ resource_type: str,
233
+ identifier: Optional[str] = None,
234
+ details: Optional[str] = None,
235
+ ) -> str:
236
+ """Format standardized error messages.
237
+
238
+ Args:
239
+ operation: Operation that failed
240
+ resource_type: Type of resource
241
+ identifier: Resource identifier (optional)
242
+ details: Additional error details (optional)
243
+
244
+ Returns:
245
+ Formatted error message
246
+ """
247
+ base_message = f"✗ Failed to {operation} {resource_type}"
248
+
249
+ if identifier:
250
+ base_message += f" '{identifier}'"
251
+
252
+ if details:
253
+ base_message += f": {details}"
254
+
255
+ return base_message
256
+
257
+
258
+ def validate_required_fields(
259
+ data: Dict[str, Any],
260
+ required_fields: List[str],
261
+ resource_type: str,
262
+ ) -> bool:
263
+ """Validate that required fields are present in data.
264
+
265
+ Args:
266
+ data: Data dictionary to validate
267
+ required_fields: List of required field names
268
+ resource_type: Type of resource (for error messages)
269
+
270
+ Returns:
271
+ True if all required fields present, False otherwise
272
+ """
273
+ missing_fields = []
274
+
275
+ for field in required_fields:
276
+ if field not in data or data[field] is None:
277
+ missing_fields.append(field)
278
+
279
+ if missing_fields:
280
+ fields_str = ", ".join(missing_fields)
281
+ click.echo(
282
+ f"✗ Missing required fields for {resource_type}: {fields_str}",
283
+ err=True,
284
+ )
285
+ return False
286
+
287
+ return True
288
+
289
+
290
+ def safe_get_nested(
291
+ data: Dict[str, Any],
292
+ path: str,
293
+ default: Any = None,
294
+ separator: str = ".",
295
+ ) -> Any:
296
+ """Safely get nested dictionary values.
297
+
298
+ Args:
299
+ data: Dictionary to search
300
+ path: Dot-separated path (e.g., "metadata.tags.name")
301
+ default: Default value if path not found
302
+ separator: Path separator character
303
+
304
+ Returns:
305
+ Value at path or default
306
+ """
307
+ try:
308
+ keys = path.split(separator)
309
+ value = data
310
+
311
+ for key in keys:
312
+ value = value[key]
313
+
314
+ return value
315
+
316
+ except (KeyError, TypeError, AttributeError):
317
+ return default
318
+
319
+
320
+ def truncate_string(text: str, max_length: int = 50, suffix: str = "...") -> str:
321
+ """Truncate string for table display.
322
+
323
+ Args:
324
+ text: Text to truncate
325
+ max_length: Maximum length
326
+ suffix: Suffix for truncated text
327
+
328
+ Returns:
329
+ Truncated string
330
+ """
331
+ if not text or len(text) <= max_length:
332
+ return text or ""
333
+
334
+ return text[: max_length - len(suffix)] + suffix
335
+
336
+
337
+ def paginate_list_output(
338
+ items: List[Dict[str, Any]],
339
+ page_size: int = 25,
340
+ format_output: str = "table",
341
+ formatter_func: Optional[Callable[[Dict[str, Any]], List[str]]] = None,
342
+ headers: Optional[List[str]] = None,
343
+ column_widths: Optional[List[int]] = None,
344
+ empty_message: str = "No items found.",
345
+ total_label: str = "item(s)",
346
+ ) -> None:
347
+ """Paginate list output with user prompts to continue.
348
+
349
+ For table format, shows pages of results with user prompts.
350
+ For JSON format, shows all results at once (no pagination).
351
+
352
+ Args:
353
+ items: List of items to paginate
354
+ page_size: Number of items per page (default: 25)
355
+ format_output: 'json' or 'table'
356
+ formatter_func: Function to format table rows
357
+ headers: Table headers
358
+ column_widths: Table column widths
359
+ empty_message: Message when no items found
360
+ total_label: Label for count display
361
+ """
362
+ if not items:
363
+ if format_output.lower() == "json":
364
+ click.echo("[]")
365
+ else:
366
+ click.echo(empty_message)
367
+ return
368
+
369
+ # For JSON format, show all results at once (no pagination)
370
+ if format_output.lower() == "json":
371
+ import json
372
+
373
+ click.echo(json.dumps(items, indent=2))
374
+ return
375
+
376
+ # Table format with pagination
377
+ total_items = len(items)
378
+ current_page = 0
379
+ items_shown = 0
380
+
381
+ while items_shown < total_items:
382
+ # Calculate slice for current page
383
+ start_idx = current_page * page_size
384
+ end_idx = min(start_idx + page_size, total_items)
385
+ page_items = items[start_idx:end_idx]
386
+
387
+ # Show the page using table_utils
388
+ if formatter_func and headers and column_widths:
389
+ # For individual pages, don't show the total count footer
390
+ # We'll handle that in our pagination logic
391
+ _output_formatted_page(
392
+ page_items,
393
+ headers,
394
+ column_widths,
395
+ formatter_func,
396
+ )
397
+ else:
398
+ # Fallback to simple display
399
+ for item in page_items:
400
+ click.echo(str(item))
401
+
402
+ items_shown = end_idx
403
+
404
+ # Show pagination info and prompt if there are more items
405
+ if items_shown < total_items:
406
+ remaining = total_items - items_shown
407
+ click.echo(
408
+ f"\nShowing {items_shown} of {total_items} {total_label}. "
409
+ f"{remaining} more available."
410
+ )
411
+
412
+ # Check if we're in an interactive environment
413
+ # If stdin is not a TTY or we're in a test environment, disable interactive pagination
414
+ import os
415
+ import sys
416
+
417
+ is_non_interactive = (
418
+ not sys.stdin.isatty() # Piped input
419
+ or not sys.stdout.isatty() # Piped output
420
+ or os.getenv("CI") == "true" # CI environment
421
+ or os.getenv("PYTEST_CURRENT_TEST") is not None # pytest
422
+ or os.getenv("SLCLI_NON_INTERACTIVE") == "true" # Explicit override
423
+ )
424
+
425
+ if is_non_interactive:
426
+ click.echo("Non-interactive environment detected. Showing all remaining results...")
427
+ # Continue to show all remaining pages without prompting
428
+ current_page += 1
429
+ continue
430
+
431
+ if not questionary.confirm("Show next 25 results?", default=True).ask():
432
+ break
433
+
434
+ click.echo() # Add blank line between pages
435
+ current_page += 1
436
+ else:
437
+ # Show final count
438
+ click.echo(f"\nTotal: {total_items} {total_label}")
439
+ break
440
+
441
+
442
+ def _output_formatted_page(
443
+ items: List[Dict[str, Any]],
444
+ headers: List[str],
445
+ column_widths: List[int],
446
+ row_formatter_func: Callable[[Dict[str, Any]], List[str]],
447
+ ) -> None:
448
+ """Output a single page of formatted table data without footer.
449
+
450
+ Args:
451
+ items: List of items to output
452
+ headers: List of header names for table output
453
+ column_widths: List of column widths for table formatting
454
+ row_formatter_func: Function that converts item to list of column values
455
+ """
456
+ if not items:
457
+ return
458
+
459
+ # Table format with box-drawing characters (without footer)
460
+ if len(headers) != len(column_widths):
461
+ raise ValueError("Headers and column_widths must have the same length")
462
+
463
+ # Top border
464
+ border_chars = ["┌"] + [("─" * (w + 2)) for w in column_widths]
465
+ border_line = border_chars[0] + border_chars[1]
466
+ for part in border_chars[2:]:
467
+ border_line += "┬" + part
468
+ border_line += "┐"
469
+ click.echo(border_line)
470
+
471
+ # Header row
472
+ header_parts = ["│"]
473
+ for header, width in zip(headers, column_widths):
474
+ header_parts.append(f" {header:<{width}} │")
475
+ click.echo("".join(header_parts))
476
+
477
+ # Middle border
478
+ border_chars = ["├"] + [("─" * (w + 2)) for w in column_widths]
479
+ border_line = border_chars[0] + border_chars[1]
480
+ for part in border_chars[2:]:
481
+ border_line += "┼" + part
482
+ border_line += "┤"
483
+ click.echo(border_line)
484
+
485
+ # Data rows
486
+ for item in items:
487
+ row_data = row_formatter_func(item)
488
+ if len(row_data) != len(column_widths):
489
+ raise ValueError("Row data must match column count")
490
+
491
+ row_parts = ["│"]
492
+ for value, width in zip(row_data, column_widths):
493
+ # Truncate if necessary
494
+ str_value = str(value or "")[:width]
495
+ row_parts.append(f" {str_value:<{width}} │")
496
+ click.echo("".join(row_parts))
497
+
498
+ # Bottom border
499
+ border_chars = ["└"] + [("─" * (w + 2)) for w in column_widths]
500
+ border_line = border_chars[0] + border_chars[1]
501
+ for part in border_chars[2:]:
502
+ border_line += "┴" + part
503
+ border_line += "┘"
504
+ click.echo(border_line)