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/templates_click.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""CLI commands for managing SystemLink test plan templates."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import questionary
|
|
10
|
+
|
|
11
|
+
from .cli_utils import validate_output_format
|
|
12
|
+
from .platform import require_feature
|
|
13
|
+
from .universal_handlers import UniversalResponseHandler, FilteredResponse
|
|
14
|
+
from .utils import (
|
|
15
|
+
ExitCodes,
|
|
16
|
+
extract_error_type,
|
|
17
|
+
get_base_url,
|
|
18
|
+
get_workspace_map,
|
|
19
|
+
handle_api_error,
|
|
20
|
+
load_json_file,
|
|
21
|
+
make_api_request,
|
|
22
|
+
sanitize_filename,
|
|
23
|
+
)
|
|
24
|
+
from .workspace_utils import (
|
|
25
|
+
get_effective_workspace,
|
|
26
|
+
get_workspace_display_name,
|
|
27
|
+
resolve_workspace_filter,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _escape_filter_value(value: str) -> str:
|
|
32
|
+
"""Escape quotes and backslashes for filter literals."""
|
|
33
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_template_search_filter(search: str) -> str:
|
|
37
|
+
"""Build a case-insensitive substring filter across key template fields.
|
|
38
|
+
|
|
39
|
+
Avoid ToLower() and use Contains() with several case variants.
|
|
40
|
+
Uses field names: name, templateGroup, description.
|
|
41
|
+
"""
|
|
42
|
+
original = _escape_filter_value(search)
|
|
43
|
+
lower = _escape_filter_value(search.lower())
|
|
44
|
+
upper = _escape_filter_value(search.upper())
|
|
45
|
+
title = _escape_filter_value(search.title())
|
|
46
|
+
|
|
47
|
+
def variants(field: str) -> str:
|
|
48
|
+
return " or ".join(
|
|
49
|
+
[
|
|
50
|
+
f'{field}.Contains("{original}")',
|
|
51
|
+
f'{field}.Contains("{lower}")',
|
|
52
|
+
f'{field}.Contains("{upper}")',
|
|
53
|
+
f'{field}.Contains("{title}")',
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
fields = ["name", "templateGroup", "description"]
|
|
58
|
+
clauses = [f"({variants(f)})" for f in fields]
|
|
59
|
+
return f"(({' or '.join(clauses)}))"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _query_all_templates(
|
|
63
|
+
workspace_filter: Optional[str] = None,
|
|
64
|
+
workspace_map: Optional[dict] = None,
|
|
65
|
+
search_text: Optional[str] = None,
|
|
66
|
+
) -> List[Dict[str, Any]]:
|
|
67
|
+
"""Query all test plan templates using continuation token pagination.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
workspace_filter: Optional workspace ID to filter by
|
|
71
|
+
workspace_map: Optional workspace mapping to avoid repeated lookups
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of all templates, optionally filtered by workspace
|
|
75
|
+
"""
|
|
76
|
+
url = f"{get_base_url()}/niworkorder/v1/query-testplan-templates"
|
|
77
|
+
all_templates = []
|
|
78
|
+
continuation_token = None
|
|
79
|
+
|
|
80
|
+
while True:
|
|
81
|
+
# Build payload for the request
|
|
82
|
+
payload = {
|
|
83
|
+
"take": 100, # Use smaller page size for efficient pagination
|
|
84
|
+
"orderBy": "TEMPLATE_GROUP",
|
|
85
|
+
"descending": False,
|
|
86
|
+
"projection": ["ID", "NAME", "WORKSPACE", "TEMPLATE_GROUP"],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Add workspace filter and user filter if specified
|
|
90
|
+
filter_parts: List[str] = []
|
|
91
|
+
if workspace_filter:
|
|
92
|
+
filter_parts.append(f'WORKSPACE == "{workspace_filter}"')
|
|
93
|
+
if search_text:
|
|
94
|
+
filter_parts.append(_build_template_search_filter(search_text))
|
|
95
|
+
if filter_parts:
|
|
96
|
+
payload["filter"] = " and ".join(filter_parts)
|
|
97
|
+
|
|
98
|
+
# Add continuation token if we have one
|
|
99
|
+
if continuation_token:
|
|
100
|
+
payload["continuationToken"] = continuation_token
|
|
101
|
+
|
|
102
|
+
resp = make_api_request("POST", url, payload)
|
|
103
|
+
data = resp.json()
|
|
104
|
+
|
|
105
|
+
# Extract templates from this page
|
|
106
|
+
templates = data.get("testPlanTemplates", [])
|
|
107
|
+
all_templates.extend(templates)
|
|
108
|
+
|
|
109
|
+
# Check if there are more pages
|
|
110
|
+
continuation_token = data.get("continuationToken")
|
|
111
|
+
if not continuation_token:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
return all_templates
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def register_templates_commands(cli: Any) -> None:
|
|
118
|
+
"""Register the 'template' command group and its subcommands."""
|
|
119
|
+
|
|
120
|
+
@cli.group()
|
|
121
|
+
@click.pass_context
|
|
122
|
+
def template(ctx: click.Context) -> None:
|
|
123
|
+
"""Manage test plan templates."""
|
|
124
|
+
# Check for platform feature availability
|
|
125
|
+
# Only check if a subcommand is being invoked (not just --help)
|
|
126
|
+
if ctx.invoked_subcommand is not None:
|
|
127
|
+
require_feature("templates")
|
|
128
|
+
|
|
129
|
+
@template.command(name="init")
|
|
130
|
+
@click.option(
|
|
131
|
+
"--name",
|
|
132
|
+
"-n",
|
|
133
|
+
help="Template name (will prompt if not provided)",
|
|
134
|
+
)
|
|
135
|
+
@click.option(
|
|
136
|
+
"--template-group",
|
|
137
|
+
"-g",
|
|
138
|
+
help="Template group (will prompt if not provided)",
|
|
139
|
+
)
|
|
140
|
+
@click.option(
|
|
141
|
+
"--output",
|
|
142
|
+
"-o",
|
|
143
|
+
help="Output file path (default: <name>-template.json)",
|
|
144
|
+
)
|
|
145
|
+
def init_template(
|
|
146
|
+
name: Optional[str], template_group: Optional[str], output: Optional[str]
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Create a template JSON scaffold.
|
|
149
|
+
|
|
150
|
+
Creates a template JSON file with the required schema structure.
|
|
151
|
+
Name and Template Group are mandatory, all other fields are optional.
|
|
152
|
+
"""
|
|
153
|
+
# Prompt for required fields if not provided
|
|
154
|
+
if not name:
|
|
155
|
+
name = click.prompt("Template name", type=str)
|
|
156
|
+
if not template_group:
|
|
157
|
+
template_group = click.prompt("Template group", type=str)
|
|
158
|
+
|
|
159
|
+
# At this point, name and template_group are guaranteed to be strings
|
|
160
|
+
assert name is not None
|
|
161
|
+
assert template_group is not None
|
|
162
|
+
|
|
163
|
+
# Generate output filename if not provided
|
|
164
|
+
if not output:
|
|
165
|
+
safe_name = sanitize_filename(name, "template")
|
|
166
|
+
output = f"{safe_name}-template.json"
|
|
167
|
+
|
|
168
|
+
# Create template structure based on the schema
|
|
169
|
+
template_data = {
|
|
170
|
+
"testPlanTemplates": [
|
|
171
|
+
{
|
|
172
|
+
# Required fields
|
|
173
|
+
"name": name,
|
|
174
|
+
"templateGroup": template_group,
|
|
175
|
+
# Optional fields - customize as needed
|
|
176
|
+
"productFamilies": ["// Add product families like: cRIO, BTS, PXI, etc."],
|
|
177
|
+
"partNumbers": ["// Add specific part numbers like: 156502A-11L, ADC-1688"],
|
|
178
|
+
"summary": "// Brief summary of what this template does",
|
|
179
|
+
"description": "// Detailed description of the test template and its purpose",
|
|
180
|
+
"testProgram": "// Name of the test program to execute",
|
|
181
|
+
"estimatedDurationInSeconds": 3600,
|
|
182
|
+
"systemFilter": '// Filter expression for system selection, e.g., properties.data[\\"Lab\\"] = \\"Battery Pack Lab\\"',
|
|
183
|
+
"executionActions": [
|
|
184
|
+
{
|
|
185
|
+
"type": "JOB",
|
|
186
|
+
"action": "START",
|
|
187
|
+
"jobs": [
|
|
188
|
+
{
|
|
189
|
+
"functions": ["state.apply"],
|
|
190
|
+
"arguments": [["<properties.startTestStateId>"]],
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"type": "NOTEBOOK",
|
|
196
|
+
"action": "PAUSE",
|
|
197
|
+
"notebookId": "// UUID of notebook to pause",
|
|
198
|
+
"parameters": {"operation": "pause"},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"type": "NOTEBOOK",
|
|
202
|
+
"action": "RESUME",
|
|
203
|
+
"notebookId": "// UUID of notebook to resume",
|
|
204
|
+
"parameters": {"operation": "resume"},
|
|
205
|
+
},
|
|
206
|
+
{"type": "MANUAL", "action": "ABORT"},
|
|
207
|
+
{
|
|
208
|
+
"type": "NOTEBOOK",
|
|
209
|
+
"action": "END",
|
|
210
|
+
"notebookId": "// UUID of final notebook to execute",
|
|
211
|
+
"parameters": {
|
|
212
|
+
"partNumber": "<partNumber>",
|
|
213
|
+
"dut": "<dutId>",
|
|
214
|
+
"operator": "<assignedTo>",
|
|
215
|
+
"testProgram": "<testProgram>",
|
|
216
|
+
"location": "<properties.region>-<properties.facility>-<properties.lab>",
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
"fileIds": ["// Array of file UUIDs associated with this template"],
|
|
221
|
+
"workspace": "// UUID of the workspace where this template belongs",
|
|
222
|
+
"properties": {
|
|
223
|
+
"region": "// Example: Austin",
|
|
224
|
+
"facility": "// Example: Building A",
|
|
225
|
+
"lab": "// Example: Battery Pack Lab",
|
|
226
|
+
"startTestStateId": "// UUID for initial test state",
|
|
227
|
+
},
|
|
228
|
+
"workflowId": "// Optional: UUID of associated workflow",
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
# Check if file already exists
|
|
235
|
+
if os.path.exists(output):
|
|
236
|
+
if not questionary.confirm(
|
|
237
|
+
f"File {output} already exists. Overwrite?",
|
|
238
|
+
default=False,
|
|
239
|
+
).ask():
|
|
240
|
+
click.echo("Template initialization cancelled.")
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# Save the template file
|
|
244
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
245
|
+
json.dump(template_data, f, indent=2, ensure_ascii=False)
|
|
246
|
+
|
|
247
|
+
click.echo(f"✓ Template initialized: {output}")
|
|
248
|
+
click.echo("Edit the file to customize your template:")
|
|
249
|
+
click.echo(" - name and templateGroup are required")
|
|
250
|
+
click.echo(
|
|
251
|
+
" - All other fields are optional (remove unused fields or set appropriate values)"
|
|
252
|
+
)
|
|
253
|
+
click.echo(" - Use 'slcli templates import' to upload the template when ready")
|
|
254
|
+
click.echo(
|
|
255
|
+
" - See TestPlanTemplate.json for a complete example with execution actions"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
click.echo(f"✗ Error creating template file: {exc}", err=True)
|
|
260
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
261
|
+
|
|
262
|
+
@template.command(name="list")
|
|
263
|
+
@click.option(
|
|
264
|
+
"--workspace",
|
|
265
|
+
"-w",
|
|
266
|
+
help="Filter by workspace name or ID",
|
|
267
|
+
)
|
|
268
|
+
@click.option(
|
|
269
|
+
"--take",
|
|
270
|
+
"-t",
|
|
271
|
+
type=int,
|
|
272
|
+
default=25,
|
|
273
|
+
show_default=True,
|
|
274
|
+
help="Maximum number of templates to return",
|
|
275
|
+
)
|
|
276
|
+
@click.option(
|
|
277
|
+
"--format",
|
|
278
|
+
"-f",
|
|
279
|
+
type=click.Choice(["table", "json"]),
|
|
280
|
+
default="table",
|
|
281
|
+
show_default=True,
|
|
282
|
+
help="Output format",
|
|
283
|
+
)
|
|
284
|
+
@click.option(
|
|
285
|
+
"--filter",
|
|
286
|
+
"filter_text",
|
|
287
|
+
help="Case-insensitive substring to match name/group/description",
|
|
288
|
+
)
|
|
289
|
+
def list_templates(
|
|
290
|
+
workspace: Optional[str] = None,
|
|
291
|
+
take: int = 25,
|
|
292
|
+
format: str = "table",
|
|
293
|
+
filter_text: Optional[str] = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""List test plan templates."""
|
|
296
|
+
format_output = validate_output_format(format)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
workspace_map = get_workspace_map()
|
|
300
|
+
|
|
301
|
+
# Resolve workspace filter to ID if specified
|
|
302
|
+
workspace_id = None
|
|
303
|
+
workspace = get_effective_workspace(workspace)
|
|
304
|
+
if workspace:
|
|
305
|
+
workspace_id = resolve_workspace_filter(workspace, workspace_map)
|
|
306
|
+
|
|
307
|
+
# Use continuation token pagination to get all templates
|
|
308
|
+
all_templates = _query_all_templates(workspace_id, workspace_map, filter_text)
|
|
309
|
+
|
|
310
|
+
# Create a mock response with all data
|
|
311
|
+
resp: Any = FilteredResponse({"testPlanTemplates": all_templates})
|
|
312
|
+
|
|
313
|
+
# Use universal response handler with template formatter
|
|
314
|
+
def template_formatter(template: dict) -> list:
|
|
315
|
+
ws_guid = template.get("workspace", "")
|
|
316
|
+
ws_name = get_workspace_display_name(ws_guid, workspace_map)
|
|
317
|
+
return [
|
|
318
|
+
template.get("name", "Unknown"),
|
|
319
|
+
ws_name,
|
|
320
|
+
template.get("id", ""),
|
|
321
|
+
template.get("templateGroup", "N/A"),
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
UniversalResponseHandler.handle_list_response(
|
|
325
|
+
resp=resp,
|
|
326
|
+
data_key="testPlanTemplates",
|
|
327
|
+
item_name="template",
|
|
328
|
+
format_output=format_output,
|
|
329
|
+
formatter_func=template_formatter,
|
|
330
|
+
headers=["Name", "Workspace", "Template ID", "Group"],
|
|
331
|
+
column_widths=[40, 30, 36, 25],
|
|
332
|
+
empty_message="No test plan templates found.",
|
|
333
|
+
enable_pagination=True,
|
|
334
|
+
page_size=take,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
except Exception as exc:
|
|
338
|
+
handle_api_error(exc)
|
|
339
|
+
|
|
340
|
+
@template.command(name="get")
|
|
341
|
+
@click.option("--id", "-i", "template_id", help="Test plan template ID")
|
|
342
|
+
@click.option("--name", "-n", "template_name", help="Test plan template name")
|
|
343
|
+
@click.option(
|
|
344
|
+
"--format",
|
|
345
|
+
"-f",
|
|
346
|
+
type=click.Choice(["table", "json"]),
|
|
347
|
+
default="table",
|
|
348
|
+
show_default=True,
|
|
349
|
+
help="Output format",
|
|
350
|
+
)
|
|
351
|
+
def get_template(
|
|
352
|
+
template_id: Optional[str] = None,
|
|
353
|
+
template_name: Optional[str] = None,
|
|
354
|
+
format: str = "table",
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Show template details by ID or name."""
|
|
357
|
+
if not template_id and not template_name:
|
|
358
|
+
click.echo("✗ Must provide either --id or --name.", err=True)
|
|
359
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
360
|
+
if template_id and template_name:
|
|
361
|
+
click.echo("✗ Cannot specify both --id and --name.", err=True)
|
|
362
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
363
|
+
|
|
364
|
+
format_output = validate_output_format(format)
|
|
365
|
+
url = f"{get_base_url()}/niworkorder/v1/query-testplan-templates"
|
|
366
|
+
|
|
367
|
+
# Build filter based on what was provided
|
|
368
|
+
if template_id:
|
|
369
|
+
filter_str = f'ID == "{template_id}"'
|
|
370
|
+
else:
|
|
371
|
+
filter_str = f'NAME == "{template_name}"'
|
|
372
|
+
|
|
373
|
+
payload = {"take": 1000, "filter": filter_str}
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
resp = make_api_request("POST", url, payload)
|
|
377
|
+
data = resp.json()
|
|
378
|
+
items = data.get("testPlanTemplates", []) if isinstance(data, dict) else []
|
|
379
|
+
|
|
380
|
+
# If searching by name, need to find exact match
|
|
381
|
+
if template_name:
|
|
382
|
+
items = [t for t in items if t.get("name") == template_name]
|
|
383
|
+
|
|
384
|
+
if not items:
|
|
385
|
+
identifier = template_id if template_id else template_name
|
|
386
|
+
click.echo(f"✗ Template '{identifier}' not found.", err=True)
|
|
387
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
388
|
+
|
|
389
|
+
template = items[0]
|
|
390
|
+
|
|
391
|
+
if format_output == "json":
|
|
392
|
+
click.echo(json.dumps(template, indent=2))
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
# Table format
|
|
396
|
+
workspace_map = get_workspace_map()
|
|
397
|
+
ws_name = get_workspace_display_name(
|
|
398
|
+
template.get("workspace", ""),
|
|
399
|
+
workspace_map,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
click.echo("Template Details:")
|
|
403
|
+
click.echo("=" * 50)
|
|
404
|
+
click.echo(f"Name: {template.get('name', 'N/A')}")
|
|
405
|
+
click.echo(f"ID: {template.get('id', 'N/A')}")
|
|
406
|
+
click.echo(f"Workspace: {ws_name}")
|
|
407
|
+
click.echo(f"Group: {template.get('templateGroup', 'N/A')}")
|
|
408
|
+
click.echo(f"Description: {template.get('description', 'N/A')}")
|
|
409
|
+
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
handle_api_error(exc)
|
|
412
|
+
|
|
413
|
+
@template.command(name="export")
|
|
414
|
+
@click.option("--id", "-i", "template_id", help="Test plan template ID to export")
|
|
415
|
+
@click.option("--name", "-n", "template_name", help="Test plan template name to export")
|
|
416
|
+
@click.option("--output", "-o", help="Output JSON file (default: <template-name>.json)")
|
|
417
|
+
def export_template(
|
|
418
|
+
template_id: Optional[str] = None,
|
|
419
|
+
template_name: Optional[str] = None,
|
|
420
|
+
output: Optional[str] = None,
|
|
421
|
+
) -> None:
|
|
422
|
+
"""Export a template to JSON by ID or name."""
|
|
423
|
+
if not template_id and not template_name:
|
|
424
|
+
click.echo("✗ Must provide either --id or --name.", err=True)
|
|
425
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
426
|
+
if template_id and template_name:
|
|
427
|
+
click.echo("✗ Cannot specify both --id and --name.", err=True)
|
|
428
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
429
|
+
|
|
430
|
+
url = f"{get_base_url()}/niworkorder/v1/query-testplan-templates"
|
|
431
|
+
|
|
432
|
+
# Build filter based on what was provided
|
|
433
|
+
if template_id:
|
|
434
|
+
filter_str = f'ID == "{template_id}"'
|
|
435
|
+
else:
|
|
436
|
+
filter_str = f'NAME == "{template_name}"'
|
|
437
|
+
|
|
438
|
+
payload = {"take": 1000, "filter": filter_str}
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
resp = make_api_request("POST", url, payload)
|
|
442
|
+
data = resp.json()
|
|
443
|
+
items = data.get("testPlanTemplates", []) if isinstance(data, dict) else []
|
|
444
|
+
# If searching by name, need to find exact match (filter is case-insensitive substring)
|
|
445
|
+
if template_name:
|
|
446
|
+
items = [t for t in items if t.get("name") == template_name]
|
|
447
|
+
|
|
448
|
+
if not items:
|
|
449
|
+
identifier = template_id if template_id else template_name
|
|
450
|
+
click.echo(f"✗ Test plan template '{identifier}' not found.", err=True)
|
|
451
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
452
|
+
|
|
453
|
+
template_data = items[0]
|
|
454
|
+
|
|
455
|
+
# Generate output filename if not provided
|
|
456
|
+
if not output:
|
|
457
|
+
template_name = template_data.get("name", f"template-{template_id}")
|
|
458
|
+
safe_name = sanitize_filename(template_name, f"template-{template_id}")
|
|
459
|
+
output = f"{safe_name}.json"
|
|
460
|
+
|
|
461
|
+
# Use universal export handler
|
|
462
|
+
UniversalResponseHandler.handle_export_response(
|
|
463
|
+
resp=resp,
|
|
464
|
+
item_name="template",
|
|
465
|
+
output_file=output,
|
|
466
|
+
success_message_template="✓ Template exported to {output_file}",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
handle_api_error(exc)
|
|
471
|
+
|
|
472
|
+
@template.command(name="import")
|
|
473
|
+
@click.option(
|
|
474
|
+
"--file",
|
|
475
|
+
"input_file",
|
|
476
|
+
required=True,
|
|
477
|
+
help="Input JSON file",
|
|
478
|
+
)
|
|
479
|
+
def import_template(input_file: str) -> None:
|
|
480
|
+
"""Import a template from JSON."""
|
|
481
|
+
from .utils import check_readonly_mode
|
|
482
|
+
|
|
483
|
+
check_readonly_mode("import a template")
|
|
484
|
+
|
|
485
|
+
url = f"{get_base_url()}/niworkorder/v1/testplan-templates"
|
|
486
|
+
allowed_fields = {
|
|
487
|
+
"name",
|
|
488
|
+
"templateGroup",
|
|
489
|
+
"productFamilies",
|
|
490
|
+
"partNumbers",
|
|
491
|
+
"summary",
|
|
492
|
+
"description",
|
|
493
|
+
"testProgram",
|
|
494
|
+
"estimatedDurationInSeconds",
|
|
495
|
+
"systemFilter",
|
|
496
|
+
"executionActions",
|
|
497
|
+
"fileIds",
|
|
498
|
+
"workspace",
|
|
499
|
+
"properties",
|
|
500
|
+
"dashboard",
|
|
501
|
+
"workflowId",
|
|
502
|
+
}
|
|
503
|
+
try:
|
|
504
|
+
data: Any = load_json_file(input_file)
|
|
505
|
+
if isinstance(data, dict) and "testPlanTemplates" in data:
|
|
506
|
+
data = data["testPlanTemplates"]
|
|
507
|
+
elif isinstance(data, dict):
|
|
508
|
+
data = [data]
|
|
509
|
+
# At this point, data should be a list of dicts
|
|
510
|
+
templates_data: List[Dict[str, Any]] = data if isinstance(data, list) else []
|
|
511
|
+
filtered = []
|
|
512
|
+
for entry in templates_data:
|
|
513
|
+
if isinstance(entry, dict):
|
|
514
|
+
filtered.append({k: v for k, v in entry.items() if k in allowed_fields})
|
|
515
|
+
payload = {"testPlanTemplates": filtered}
|
|
516
|
+
|
|
517
|
+
resp = make_api_request("POST", url, payload)
|
|
518
|
+
|
|
519
|
+
# Check response body for partial failures, even if HTTP status is 200
|
|
520
|
+
response_data = resp.json() if resp.text.strip() else {}
|
|
521
|
+
failed_templates = response_data.get("failedTestPlanTemplates", [])
|
|
522
|
+
|
|
523
|
+
if failed_templates:
|
|
524
|
+
# Handle partial or complete failures
|
|
525
|
+
click.echo("✗ Template import failed:", err=True)
|
|
526
|
+
|
|
527
|
+
# Extract detailed error information from the response
|
|
528
|
+
main_error = response_data.get("error", {})
|
|
529
|
+
inner_errors = main_error.get("innerErrors", [])
|
|
530
|
+
|
|
531
|
+
# Create a mapping of resource IDs to error details
|
|
532
|
+
error_details = {}
|
|
533
|
+
for inner_error in inner_errors:
|
|
534
|
+
resource_id = inner_error.get("resourceId", "Unknown")
|
|
535
|
+
error_name = inner_error.get("name", "")
|
|
536
|
+
error_message = inner_error.get("message", "Unknown error")
|
|
537
|
+
resource_type = inner_error.get("resourceType", "")
|
|
538
|
+
|
|
539
|
+
error_details[resource_id] = {
|
|
540
|
+
"name": error_name,
|
|
541
|
+
"message": error_message,
|
|
542
|
+
"type": resource_type,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
# Report errors for each failed template
|
|
546
|
+
for failed_template in failed_templates:
|
|
547
|
+
template_name = failed_template.get("name", "Unknown")
|
|
548
|
+
|
|
549
|
+
# Try to find matching error details
|
|
550
|
+
error_info = error_details.get(template_name, {})
|
|
551
|
+
error_name = error_info.get("name", "")
|
|
552
|
+
error_message = error_info.get("message", "Unknown error")
|
|
553
|
+
|
|
554
|
+
# Format the error output
|
|
555
|
+
if error_name:
|
|
556
|
+
error_type = extract_error_type(error_name)
|
|
557
|
+
click.echo(f" - {template_name}: {error_type} - {error_message}", err=True)
|
|
558
|
+
else:
|
|
559
|
+
click.echo(f" - {template_name}: {error_message}", err=True)
|
|
560
|
+
|
|
561
|
+
# Show general error information if available
|
|
562
|
+
if main_error.get("message") and len(failed_templates) > 1:
|
|
563
|
+
click.echo(f"\nGeneral error: {main_error.get('message')}", err=True)
|
|
564
|
+
|
|
565
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
566
|
+
else:
|
|
567
|
+
click.echo("✓ Test plan template imported successfully.")
|
|
568
|
+
|
|
569
|
+
except Exception as exc:
|
|
570
|
+
handle_api_error(exc)
|
|
571
|
+
|
|
572
|
+
@template.command(name="delete")
|
|
573
|
+
@click.option(
|
|
574
|
+
"--id",
|
|
575
|
+
"-i",
|
|
576
|
+
"template_id",
|
|
577
|
+
required=True,
|
|
578
|
+
help="Test plan template ID to delete",
|
|
579
|
+
)
|
|
580
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this template?")
|
|
581
|
+
def delete_template(template_id: str) -> None:
|
|
582
|
+
"""Delete a template."""
|
|
583
|
+
from .utils import check_readonly_mode
|
|
584
|
+
|
|
585
|
+
check_readonly_mode("delete a template")
|
|
586
|
+
|
|
587
|
+
url = f"{get_base_url()}/niworkorder/v1/delete-testplan-templates"
|
|
588
|
+
payload = {"ids": [template_id]}
|
|
589
|
+
try:
|
|
590
|
+
resp = make_api_request("POST", url, payload)
|
|
591
|
+
if resp.status_code in (200, 204):
|
|
592
|
+
click.echo(f"✓ Test plan template {template_id} deleted successfully.")
|
|
593
|
+
else:
|
|
594
|
+
click.echo(
|
|
595
|
+
f"✗ Failed to delete test plan template {template_id}: {resp.text}", err=True
|
|
596
|
+
)
|
|
597
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
handle_api_error(exc)
|