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/dff_decorators.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Decorators and helpers for Dynamic Form Fields (DFF) testing and CLI behavior.
|
|
2
|
+
|
|
3
|
+
This module provides lightweight decorator utilities used by the CLI tests and
|
|
4
|
+
command implementations. Keep implementations minimal and well-typed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Callable, TypeVar
|
|
8
|
+
|
|
9
|
+
F = TypeVar("F", bound=Callable[..., object])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def passthrough_decorator(func: F) -> F:
|
|
13
|
+
"""A no-op decorator used in testing to preserve function metadata.
|
|
14
|
+
|
|
15
|
+
Returns the original function unchanged. Useful as a placeholder when a
|
|
16
|
+
decorator is required by test scaffolding but implements no behavior.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
func: The callable to return.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The original callable ``func``.
|
|
23
|
+
"""
|
|
24
|
+
return func
|
slcli/example_click.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""CLI commands for managing example configurations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from .example_loader import ExampleLoader
|
|
10
|
+
from .example_provisioner import ExampleProvisioner, ProvisioningAction, ProvisioningResult
|
|
11
|
+
from .universal_handlers import UniversalResponseHandler, FilteredResponse
|
|
12
|
+
from .utils import ExitCodes, format_success, get_workspace_map, handle_api_error, save_json_file
|
|
13
|
+
from .workspace_utils import get_effective_workspace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_workspace_id(workspace: Optional[str]) -> Optional[str]:
|
|
17
|
+
"""Resolve workspace name to ID using workspace map.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
workspace: Workspace name or ID provided by the user.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Workspace ID if resolved; original value if already an ID; None if not provided.
|
|
24
|
+
"""
|
|
25
|
+
if not workspace:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
workspace_map = get_workspace_map()
|
|
29
|
+
if not workspace_map:
|
|
30
|
+
return workspace
|
|
31
|
+
|
|
32
|
+
# Direct match on ID
|
|
33
|
+
if workspace in workspace_map:
|
|
34
|
+
return workspace
|
|
35
|
+
|
|
36
|
+
# Match on name (case-insensitive)
|
|
37
|
+
for ws_id, ws_name in workspace_map.items():
|
|
38
|
+
if ws_name and workspace.lower() == str(ws_name).lower():
|
|
39
|
+
return ws_id
|
|
40
|
+
|
|
41
|
+
click.echo(f"✗ Workspace '{workspace}' not found. Provide a valid name or ID.", err=True)
|
|
42
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _serialize_results(results: List[ProvisioningResult]) -> List[Dict[str, Any]]:
|
|
46
|
+
"""Convert provisioning results into serializable dictionaries."""
|
|
47
|
+
serialized: List[Dict[str, Any]] = []
|
|
48
|
+
for res in results:
|
|
49
|
+
action_value = (
|
|
50
|
+
res.action.value if isinstance(res.action, ProvisioningAction) else str(res.action)
|
|
51
|
+
)
|
|
52
|
+
serialized.append(
|
|
53
|
+
{
|
|
54
|
+
"id_reference": res.id_reference,
|
|
55
|
+
"resource_type": res.resource_type,
|
|
56
|
+
"resource_name": res.resource_name,
|
|
57
|
+
"action": action_value,
|
|
58
|
+
"server_id": res.server_id,
|
|
59
|
+
"error": res.error,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return serialized
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _result_row_formatter(item: Dict[str, Any]) -> List[str]:
|
|
66
|
+
"""Formatter for table rows in provisioning output."""
|
|
67
|
+
return [
|
|
68
|
+
item.get("resource_name", ""),
|
|
69
|
+
item.get("resource_type", ""),
|
|
70
|
+
item.get("action", ""),
|
|
71
|
+
item.get("server_id", "") or "-",
|
|
72
|
+
item.get("error", "") or "",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _example_row_formatter(item: Dict[str, Any]) -> List[str]:
|
|
77
|
+
"""Formatter for table rows in example list output."""
|
|
78
|
+
tags = item.get("tags") or []
|
|
79
|
+
setup = item.get("estimated_setup_time_minutes", 0)
|
|
80
|
+
return [
|
|
81
|
+
item.get("name", ""),
|
|
82
|
+
item.get("title", ""),
|
|
83
|
+
", ".join(tags),
|
|
84
|
+
str(setup),
|
|
85
|
+
item.get("author", ""),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _output_results(results: List[Dict[str, Any]], format_output: str) -> None:
|
|
90
|
+
"""Render provisioning results in the requested format."""
|
|
91
|
+
UniversalResponseHandler.handle_list_response(
|
|
92
|
+
resp=FilteredResponse({"resources": results}),
|
|
93
|
+
data_key="resources",
|
|
94
|
+
item_name="resource",
|
|
95
|
+
format_output=format_output,
|
|
96
|
+
formatter_func=_result_row_formatter,
|
|
97
|
+
headers=["Name", "Type", "Action", "Server ID", "Error"],
|
|
98
|
+
column_widths=[45, 12, 10, 38, 30],
|
|
99
|
+
empty_message="No resources processed.",
|
|
100
|
+
enable_pagination=False,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write_audit_log(
|
|
105
|
+
results: List[Dict[str, Any]], audit_log: Optional[str], quiet: bool = False
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Persist results to an audit log file if requested."""
|
|
108
|
+
if not audit_log:
|
|
109
|
+
return
|
|
110
|
+
save_json_file(results, audit_log)
|
|
111
|
+
if not quiet:
|
|
112
|
+
click.echo(f"Audit log saved to {audit_log}", err=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def register_example_commands(cli: Any) -> None:
|
|
116
|
+
"""Register example command group.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
cli: Click CLI group to register commands on.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
@cli.group()
|
|
123
|
+
def example() -> None:
|
|
124
|
+
"""Manage example resource configurations.
|
|
125
|
+
|
|
126
|
+
Examples help you quickly set up demo systems for training,
|
|
127
|
+
testing, or evaluation. Each example includes systems, assets,
|
|
128
|
+
DUTs, templates, and other resources needed for a complete workflow.
|
|
129
|
+
|
|
130
|
+
Workspace: Uses default workspace unless --workspace specified.
|
|
131
|
+
"""
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
@example.command(name="list")
|
|
135
|
+
@click.option(
|
|
136
|
+
"--format",
|
|
137
|
+
"-f",
|
|
138
|
+
type=click.Choice(["table", "json"]),
|
|
139
|
+
default="table",
|
|
140
|
+
help="Output format",
|
|
141
|
+
)
|
|
142
|
+
def list_examples(format: str) -> None:
|
|
143
|
+
"""List available example configurations.
|
|
144
|
+
|
|
145
|
+
Shows all examples with descriptions, tags, and estimated setup time.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
loader = ExampleLoader()
|
|
149
|
+
examples = loader.list_examples()
|
|
150
|
+
|
|
151
|
+
if not examples:
|
|
152
|
+
if format == "json":
|
|
153
|
+
click.echo("[]")
|
|
154
|
+
else:
|
|
155
|
+
click.echo("No examples available.", err=True)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if format == "json":
|
|
159
|
+
# JSON: show all at once
|
|
160
|
+
click.echo(json.dumps(examples, indent=2))
|
|
161
|
+
else:
|
|
162
|
+
UniversalResponseHandler.handle_list_response(
|
|
163
|
+
resp=FilteredResponse({"examples": examples}),
|
|
164
|
+
data_key="examples",
|
|
165
|
+
item_name="example",
|
|
166
|
+
format_output=format,
|
|
167
|
+
formatter_func=_example_row_formatter,
|
|
168
|
+
headers=["Name", "Title", "Tags", "Setup (min)", "Author"],
|
|
169
|
+
column_widths=[35, 28, 24, 11, 18],
|
|
170
|
+
enable_pagination=False, # Unlikely to have > 25 examples
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
handle_api_error(exc)
|
|
175
|
+
|
|
176
|
+
@example.command(name="info")
|
|
177
|
+
@click.argument("example_name")
|
|
178
|
+
@click.option(
|
|
179
|
+
"--format",
|
|
180
|
+
"-f",
|
|
181
|
+
type=click.Choice(["table", "json"]),
|
|
182
|
+
default="table",
|
|
183
|
+
help="Output format",
|
|
184
|
+
)
|
|
185
|
+
def info_example(example_name: str, format: str) -> None:
|
|
186
|
+
"""Show detailed information about an example.
|
|
187
|
+
|
|
188
|
+
Displays full config including resources, dependencies, and
|
|
189
|
+
estimated setup time.
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
slcli example info demo-test-plans
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
loader = ExampleLoader()
|
|
196
|
+
config = loader.load_config(example_name)
|
|
197
|
+
|
|
198
|
+
if format == "json":
|
|
199
|
+
# JSON: dump full config
|
|
200
|
+
click.echo(json.dumps(config, indent=2))
|
|
201
|
+
else:
|
|
202
|
+
# Table format: show summary and resources
|
|
203
|
+
click.echo(f"\n{'='*70}")
|
|
204
|
+
click.echo(f"Example: {config['title']}")
|
|
205
|
+
click.echo(f"{'='*70}")
|
|
206
|
+
click.echo(f"Name: {config.get('name', 'N/A')}")
|
|
207
|
+
click.echo(f"Author: {config.get('author', 'N/A')}")
|
|
208
|
+
click.echo(f"Setup Time: {config.get('estimated_setup_time_minutes', 0)} minutes")
|
|
209
|
+
click.echo(f"Tags: {', '.join(config.get('tags', []))}")
|
|
210
|
+
click.echo()
|
|
211
|
+
click.echo(f"Description:\n{config.get('description', 'N/A')}")
|
|
212
|
+
click.echo()
|
|
213
|
+
|
|
214
|
+
# Show resources
|
|
215
|
+
resources = config.get("resources", [])
|
|
216
|
+
click.echo(f"\nResources ({len(resources)} total):")
|
|
217
|
+
click.echo("-" * 70)
|
|
218
|
+
|
|
219
|
+
for resource in resources:
|
|
220
|
+
res_type = resource.get("type", "unknown")
|
|
221
|
+
res_name = resource.get("name", "N/A")
|
|
222
|
+
res_ref = resource.get("id_reference", "N/A")
|
|
223
|
+
click.echo(f" {res_type:15} {res_name:30} (${{{res_ref}}})")
|
|
224
|
+
|
|
225
|
+
click.echo(f"{'='*70}\n")
|
|
226
|
+
|
|
227
|
+
except FileNotFoundError as e:
|
|
228
|
+
click.echo(f"✗ Error: {e}", err=True)
|
|
229
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
230
|
+
except ValueError as e:
|
|
231
|
+
click.echo(f"✗ Error: {e}", err=True)
|
|
232
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
handle_api_error(exc)
|
|
235
|
+
|
|
236
|
+
@example.command(name="install")
|
|
237
|
+
@click.argument("example_name")
|
|
238
|
+
@click.option("--workspace", "-w", required=True, help="Workspace name or ID for resources")
|
|
239
|
+
@click.option(
|
|
240
|
+
"--format",
|
|
241
|
+
"-f",
|
|
242
|
+
type=click.Choice(["table", "json"]),
|
|
243
|
+
default="table",
|
|
244
|
+
help="Output format for provisioning results",
|
|
245
|
+
)
|
|
246
|
+
@click.option(
|
|
247
|
+
"--dry-run",
|
|
248
|
+
is_flag=True,
|
|
249
|
+
help="Validate and preview resource creation without calling APIs.",
|
|
250
|
+
)
|
|
251
|
+
@click.option(
|
|
252
|
+
"--audit-log",
|
|
253
|
+
"-a",
|
|
254
|
+
type=click.Path(dir_okay=False, writable=True, resolve_path=True),
|
|
255
|
+
help="Path to write provisioning results as JSON for auditing.",
|
|
256
|
+
)
|
|
257
|
+
def install_example(
|
|
258
|
+
example_name: str,
|
|
259
|
+
workspace: Optional[str],
|
|
260
|
+
format: str,
|
|
261
|
+
dry_run: bool,
|
|
262
|
+
audit_log: Optional[str],
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Provision all resources defined by an example configuration."""
|
|
265
|
+
try:
|
|
266
|
+
loader = ExampleLoader()
|
|
267
|
+
config = loader.load_config(example_name)
|
|
268
|
+
|
|
269
|
+
workspace_id = _resolve_workspace_id(get_effective_workspace(workspace))
|
|
270
|
+
provisioner = ExampleProvisioner(
|
|
271
|
+
workspace_id=workspace_id,
|
|
272
|
+
example_name=example_name,
|
|
273
|
+
dry_run=dry_run,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
results, err = provisioner.provision(config)
|
|
277
|
+
if err:
|
|
278
|
+
handle_api_error(err)
|
|
279
|
+
|
|
280
|
+
serialized = _serialize_results(results)
|
|
281
|
+
_write_audit_log(serialized, audit_log, quiet=format == "json")
|
|
282
|
+
_output_results(serialized, format)
|
|
283
|
+
|
|
284
|
+
failed = any(r.get("action") == ProvisioningAction.FAILED.value for r in serialized)
|
|
285
|
+
if failed:
|
|
286
|
+
click.echo("✗ One or more resources failed to provision.", err=True)
|
|
287
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
288
|
+
|
|
289
|
+
if format == "json":
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
created_count = sum(
|
|
293
|
+
1 for r in serialized if r.get("action") == ProvisioningAction.CREATED.value
|
|
294
|
+
)
|
|
295
|
+
skipped_count = sum(
|
|
296
|
+
1 for r in serialized if r.get("action") == ProvisioningAction.SKIPPED.value
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
summary_message = "Dry-run completed" if dry_run else "Example install completed"
|
|
300
|
+
format_success(
|
|
301
|
+
summary_message,
|
|
302
|
+
{
|
|
303
|
+
"example": example_name,
|
|
304
|
+
"workspace": workspace_id or "default",
|
|
305
|
+
"created": created_count,
|
|
306
|
+
"skipped": skipped_count,
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
except FileNotFoundError as e:
|
|
311
|
+
click.echo(f"✗ Error: {e}", err=True)
|
|
312
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
313
|
+
except ValueError as e:
|
|
314
|
+
click.echo(f"✗ Error: {e}", err=True)
|
|
315
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
handle_api_error(exc)
|
|
318
|
+
|
|
319
|
+
@example.command(name="delete")
|
|
320
|
+
@click.argument("example_name")
|
|
321
|
+
@click.option("--workspace", "-w", required=True, help="Workspace name or ID for resources")
|
|
322
|
+
@click.option(
|
|
323
|
+
"--format",
|
|
324
|
+
"-f",
|
|
325
|
+
type=click.Choice(["table", "json"]),
|
|
326
|
+
default="table",
|
|
327
|
+
help="Output format for deletion results",
|
|
328
|
+
)
|
|
329
|
+
@click.option(
|
|
330
|
+
"--dry-run",
|
|
331
|
+
is_flag=True,
|
|
332
|
+
help="Preview deletions without calling APIs.",
|
|
333
|
+
)
|
|
334
|
+
@click.option(
|
|
335
|
+
"--audit-log",
|
|
336
|
+
"-a",
|
|
337
|
+
type=click.Path(dir_okay=False, writable=True, resolve_path=True),
|
|
338
|
+
help="Path to write deletion results as JSON for auditing.",
|
|
339
|
+
)
|
|
340
|
+
def delete_example(
|
|
341
|
+
example_name: str,
|
|
342
|
+
workspace: Optional[str],
|
|
343
|
+
format: str,
|
|
344
|
+
dry_run: bool,
|
|
345
|
+
audit_log: Optional[str],
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Delete resources for an example configuration in reverse order."""
|
|
348
|
+
from .utils import check_readonly_mode
|
|
349
|
+
|
|
350
|
+
check_readonly_mode("delete an example")
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
loader = ExampleLoader()
|
|
354
|
+
config = loader.load_config(example_name)
|
|
355
|
+
|
|
356
|
+
workspace_id = _resolve_workspace_id(get_effective_workspace(workspace))
|
|
357
|
+
provisioner = ExampleProvisioner(
|
|
358
|
+
workspace_id=workspace_id,
|
|
359
|
+
example_name=example_name,
|
|
360
|
+
dry_run=dry_run,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
results, err = provisioner.delete(config)
|
|
364
|
+
if err:
|
|
365
|
+
handle_api_error(err)
|
|
366
|
+
|
|
367
|
+
serialized = _serialize_results(results)
|
|
368
|
+
_write_audit_log(serialized, audit_log, quiet=format == "json")
|
|
369
|
+
_output_results(serialized, format)
|
|
370
|
+
|
|
371
|
+
failed = any(r.get("action") == ProvisioningAction.FAILED.value for r in serialized)
|
|
372
|
+
if failed:
|
|
373
|
+
click.echo("✗ One or more resources failed to delete.", err=True)
|
|
374
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
375
|
+
|
|
376
|
+
if format == "json":
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
deleted_count = sum(
|
|
380
|
+
1 for r in serialized if r.get("action") == ProvisioningAction.DELETED.value
|
|
381
|
+
)
|
|
382
|
+
skipped_count = sum(
|
|
383
|
+
1 for r in serialized if r.get("action") == ProvisioningAction.SKIPPED.value
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
summary_message = "Dry-run completed" if dry_run else "Example delete completed"
|
|
387
|
+
format_success(
|
|
388
|
+
summary_message,
|
|
389
|
+
{
|
|
390
|
+
"example": example_name,
|
|
391
|
+
"workspace": workspace_id or "default",
|
|
392
|
+
"deleted": deleted_count,
|
|
393
|
+
"skipped": skipped_count,
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
except FileNotFoundError as e:
|
|
398
|
+
click.echo(f"✗ Error: {e}", err=True)
|
|
399
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
400
|
+
except ValueError as e:
|
|
401
|
+
click.echo(f"✗ Error: {e}", err=True)
|
|
402
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
403
|
+
except Exception as exc:
|
|
404
|
+
handle_api_error(exc)
|
slcli/example_loader.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Load and validate example configurations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import yaml # type: ignore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExampleLoader:
|
|
10
|
+
"""Load and validate example configurations from local examples/ directory."""
|
|
11
|
+
|
|
12
|
+
# Supported schema versions
|
|
13
|
+
SUPPORTED_SCHEMA_VERSIONS = {"1.0"}
|
|
14
|
+
|
|
15
|
+
# Required fields in config
|
|
16
|
+
REQUIRED_FIELDS = {"format_version", "name", "title", "resources"}
|
|
17
|
+
|
|
18
|
+
# Required resource fields
|
|
19
|
+
REQUIRED_RESOURCE_FIELDS = {"type", "name", "properties", "id_reference"}
|
|
20
|
+
|
|
21
|
+
# Supported resource types
|
|
22
|
+
SUPPORTED_RESOURCE_TYPES = {
|
|
23
|
+
"location",
|
|
24
|
+
"product",
|
|
25
|
+
"system",
|
|
26
|
+
"asset",
|
|
27
|
+
"dut",
|
|
28
|
+
"testtemplate",
|
|
29
|
+
"workflow",
|
|
30
|
+
"work_item",
|
|
31
|
+
"work_order",
|
|
32
|
+
"test_result",
|
|
33
|
+
"data_table",
|
|
34
|
+
"file",
|
|
35
|
+
"notebook",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def __init__(self, examples_dir: Optional[Path] = None) -> None:
|
|
39
|
+
"""Initialize loader with path to examples directory.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
examples_dir: Path to examples directory. Defaults to slcli/examples/.
|
|
43
|
+
"""
|
|
44
|
+
self.examples_dir = examples_dir or Path(__file__).parent / "examples"
|
|
45
|
+
self._schema: Optional[Dict[str, Any]] = None
|
|
46
|
+
|
|
47
|
+
def list_examples(self) -> List[Dict[str, Any]]:
|
|
48
|
+
"""List all available examples with metadata.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of dicts with keys: name, title, description, tags,
|
|
52
|
+
estimated_setup_time_minutes, author.
|
|
53
|
+
"""
|
|
54
|
+
examples = []
|
|
55
|
+
for example_dir in sorted(self.examples_dir.iterdir()):
|
|
56
|
+
# Skip special directories and non-directories
|
|
57
|
+
if not example_dir.is_dir() or example_dir.name.startswith("_"):
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
config_path = example_dir / "config.yaml"
|
|
61
|
+
if not config_path.exists():
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
config = self.load_config(example_dir.name)
|
|
66
|
+
examples.append(
|
|
67
|
+
{
|
|
68
|
+
"name": config["name"],
|
|
69
|
+
"title": config["title"],
|
|
70
|
+
"description": config.get("description", ""),
|
|
71
|
+
"tags": config.get("tags", []),
|
|
72
|
+
"estimated_setup_time_minutes": config.get(
|
|
73
|
+
"estimated_setup_time_minutes", 0
|
|
74
|
+
),
|
|
75
|
+
"author": config.get("author", "Unknown"),
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
except (FileNotFoundError, ValueError):
|
|
79
|
+
# Skip invalid examples
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
return examples
|
|
83
|
+
|
|
84
|
+
def load_config(self, example_name: str) -> Dict[str, Any]:
|
|
85
|
+
"""Load and validate example configuration.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
example_name: Name of example (directory name).
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Validated config dictionary.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
FileNotFoundError: If example config not found.
|
|
95
|
+
ValueError: If config fails validation.
|
|
96
|
+
"""
|
|
97
|
+
config_path = self.examples_dir / example_name / "config.yaml"
|
|
98
|
+
if not config_path.exists():
|
|
99
|
+
raise FileNotFoundError(
|
|
100
|
+
f"Example '{example_name}' not found. " f"Config path: {config_path}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Load YAML
|
|
104
|
+
try:
|
|
105
|
+
with open(config_path, "r") as f:
|
|
106
|
+
config = yaml.safe_load(f)
|
|
107
|
+
except yaml.YAMLError as e:
|
|
108
|
+
raise ValueError(f"Invalid YAML in {config_path}: {e}")
|
|
109
|
+
|
|
110
|
+
if not isinstance(config, dict):
|
|
111
|
+
raise ValueError(f"Config must be a dictionary, got {type(config)}")
|
|
112
|
+
|
|
113
|
+
# Validate schema
|
|
114
|
+
errors = self.validate_config(config)
|
|
115
|
+
if errors:
|
|
116
|
+
msg = f"Config validation failed for '{example_name}':\n"
|
|
117
|
+
msg += "\n".join(f" - {e}" for e in errors)
|
|
118
|
+
raise ValueError(msg)
|
|
119
|
+
|
|
120
|
+
# Validate references
|
|
121
|
+
ref_errors = self._validate_references(config)
|
|
122
|
+
if ref_errors:
|
|
123
|
+
msg = f"Reference validation failed for '{example_name}':\n"
|
|
124
|
+
msg += "\n".join(f" - {e}" for e in ref_errors)
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
|
|
127
|
+
return config
|
|
128
|
+
|
|
129
|
+
def validate_config(self, config: Dict[str, Any]) -> List[str]:
|
|
130
|
+
"""Validate config against basic schema requirements.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
config: Config dictionary to validate.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of validation error messages (empty if valid).
|
|
137
|
+
"""
|
|
138
|
+
errors = []
|
|
139
|
+
|
|
140
|
+
# Check all required top-level fields are present
|
|
141
|
+
|
|
142
|
+
missing_fields = self.REQUIRED_FIELDS - set(config.keys())
|
|
143
|
+
if missing_fields:
|
|
144
|
+
errors.append(f"Missing required fields: {', '.join(sorted(missing_fields))}")
|
|
145
|
+
|
|
146
|
+
# Check format_version is supported
|
|
147
|
+
version = config.get("format_version")
|
|
148
|
+
if version and version not in self.SUPPORTED_SCHEMA_VERSIONS:
|
|
149
|
+
errors.append(
|
|
150
|
+
f"Unsupported format_version '{version}'. "
|
|
151
|
+
f"Supported: {self.SUPPORTED_SCHEMA_VERSIONS}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Validate resources
|
|
155
|
+
resources = config.get("resources", [])
|
|
156
|
+
if not isinstance(resources, list):
|
|
157
|
+
errors.append("resources must be a list")
|
|
158
|
+
else:
|
|
159
|
+
for idx, resource in enumerate(resources):
|
|
160
|
+
if not isinstance(resource, dict):
|
|
161
|
+
errors.append(f"Resource {idx}: must be a dictionary")
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Check required resource fields
|
|
165
|
+
missing = self.REQUIRED_RESOURCE_FIELDS - set(resource.keys())
|
|
166
|
+
if missing:
|
|
167
|
+
errors.append(f"Resource {idx}: missing fields: {', '.join(sorted(missing))}")
|
|
168
|
+
|
|
169
|
+
# Check resource type is supported
|
|
170
|
+
res_type = resource.get("type")
|
|
171
|
+
if res_type and res_type not in self.SUPPORTED_RESOURCE_TYPES:
|
|
172
|
+
errors.append(
|
|
173
|
+
f"Resource {idx}: unsupported type '{res_type}'. "
|
|
174
|
+
f"Supported: {', '.join(sorted(self.SUPPORTED_RESOURCE_TYPES))}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Validate id_reference format (should be valid identifier)
|
|
178
|
+
id_ref = resource.get("id_reference", "")
|
|
179
|
+
if id_ref and not self._is_valid_identifier(id_ref):
|
|
180
|
+
errors.append(
|
|
181
|
+
f"Resource {idx}: invalid id_reference '{id_ref}'. "
|
|
182
|
+
f"Must start with letter or underscore, contain only "
|
|
183
|
+
f"alphanumeric and underscores."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return errors
|
|
187
|
+
|
|
188
|
+
def _is_valid_identifier(self, name: str) -> bool:
|
|
189
|
+
"""Check if name is a valid Python identifier."""
|
|
190
|
+
if not name:
|
|
191
|
+
return False
|
|
192
|
+
if not (name[0].isalpha() or name[0] == "_"):
|
|
193
|
+
return False
|
|
194
|
+
return all(c.isalnum() or c == "_" for c in name)
|
|
195
|
+
|
|
196
|
+
def _validate_references(self, config: Dict[str, Any]) -> List[str]:
|
|
197
|
+
"""Validate that all ${ref} references are defined.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
config: Config dictionary.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
List of reference error messages.
|
|
204
|
+
"""
|
|
205
|
+
errors = []
|
|
206
|
+
|
|
207
|
+
# Collect all defined id_references
|
|
208
|
+
defined_refs = set()
|
|
209
|
+
resources = config.get("resources", [])
|
|
210
|
+
if not isinstance(resources, list):
|
|
211
|
+
return ["resources must be a list"]
|
|
212
|
+
|
|
213
|
+
for resource in resources:
|
|
214
|
+
if isinstance(resource, dict):
|
|
215
|
+
ref = resource.get("id_reference")
|
|
216
|
+
if ref:
|
|
217
|
+
defined_refs.add(ref)
|
|
218
|
+
|
|
219
|
+
# Check all ${ref} references are defined
|
|
220
|
+
for resource in resources:
|
|
221
|
+
if not isinstance(resource, dict):
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# Check in resource properties
|
|
225
|
+
props = resource.get("properties", {})
|
|
226
|
+
if isinstance(props, dict):
|
|
227
|
+
ref_errors = self._collect_undefined_refs(props, defined_refs)
|
|
228
|
+
errors.extend(ref_errors)
|
|
229
|
+
|
|
230
|
+
return errors
|
|
231
|
+
|
|
232
|
+
def _collect_undefined_refs(self, obj: Any, defined_refs: set) -> List[str]:
|
|
233
|
+
"""Recursively collect undefined references from an object.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
obj: Object to scan (dict, list, string, etc).
|
|
237
|
+
defined_refs: Set of defined id_references.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of error messages for undefined references.
|
|
241
|
+
"""
|
|
242
|
+
errors = []
|
|
243
|
+
|
|
244
|
+
if isinstance(obj, dict):
|
|
245
|
+
for value in obj.values():
|
|
246
|
+
errors.extend(self._collect_undefined_refs(value, defined_refs))
|
|
247
|
+
elif isinstance(obj, list):
|
|
248
|
+
for item in obj:
|
|
249
|
+
errors.extend(self._collect_undefined_refs(item, defined_refs))
|
|
250
|
+
elif isinstance(obj, str):
|
|
251
|
+
# Check for ${ref} pattern
|
|
252
|
+
if obj.startswith("${") and obj.endswith("}"):
|
|
253
|
+
ref = obj[2:-1]
|
|
254
|
+
if ref not in defined_refs:
|
|
255
|
+
errors.append(
|
|
256
|
+
f"Undefined reference: {obj}. "
|
|
257
|
+
f"Defined references: {sorted(defined_refs)}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return errors
|
|
261
|
+
|
|
262
|
+
def get_resource_order(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
263
|
+
"""Get resources in provisioning order (as listed in config).
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
config: Config dictionary.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List of resource definitions.
|
|
270
|
+
"""
|
|
271
|
+
resources = config.get("resources", [])
|
|
272
|
+
if not isinstance(resources, list):
|
|
273
|
+
return []
|
|
274
|
+
return [r for r in resources if isinstance(r, dict)]
|