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
|
@@ -0,0 +1,2777 @@
|
|
|
1
|
+
"""Provision SLE resources from example configurations.
|
|
2
|
+
|
|
3
|
+
Implements resource provisioning with real API calls to SystemLink Enterprise.
|
|
4
|
+
Supports dry-run mode for validation without creating resources.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json as json_module
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .utils import get_base_url, get_headers, make_api_request
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProvisioningAction(Enum):
|
|
21
|
+
"""Type of action taken by the provisioner."""
|
|
22
|
+
|
|
23
|
+
CREATED = "created"
|
|
24
|
+
SKIPPED = "skipped"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
DELETED = "deleted"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ProvisioningResult:
|
|
31
|
+
"""Result of provisioning a single resource.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
id_reference: Local identifier defined in config (e.g., "sys_ts1").
|
|
35
|
+
resource_type: Resource type (location, product, system, asset, dut, testtemplate).
|
|
36
|
+
resource_name: Human-readable name.
|
|
37
|
+
action: Action taken (created/skipped/failed).
|
|
38
|
+
server_id: Simulated server ID for created resource.
|
|
39
|
+
error: Error message if provisioning failed.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
id_reference: str
|
|
43
|
+
resource_type: str
|
|
44
|
+
resource_name: str
|
|
45
|
+
action: ProvisioningAction
|
|
46
|
+
server_id: Optional[str] = None
|
|
47
|
+
error: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExampleProvisioner:
|
|
51
|
+
"""Provision resources to SLE.
|
|
52
|
+
|
|
53
|
+
Provides dry-run mode to validate and plan without creating resources.
|
|
54
|
+
Tags resources with example name for cleanup.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
workspace_id: Optional[str] = None,
|
|
60
|
+
example_name: Optional[str] = None,
|
|
61
|
+
dry_run: bool = False,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Initialize the provisioner.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
workspace_id: Workspace identifier (ID).
|
|
67
|
+
example_name: Example name for tagging resources.
|
|
68
|
+
dry_run: When True, does not create any resources (SKIPPED).
|
|
69
|
+
"""
|
|
70
|
+
self.workspace_id = workspace_id
|
|
71
|
+
self.example_name = example_name
|
|
72
|
+
self.dry_run = dry_run
|
|
73
|
+
self.id_map: Dict[str, str] = {}
|
|
74
|
+
self._test_results_deleted: bool = False
|
|
75
|
+
self._files_deleted: bool = False
|
|
76
|
+
self._notebooks_deleted: bool = False
|
|
77
|
+
|
|
78
|
+
def provision(
|
|
79
|
+
self, config: Dict[str, Any]
|
|
80
|
+
) -> Tuple[List[ProvisioningResult], Optional[Exception]]:
|
|
81
|
+
"""Provision all resources in the provided config.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
config: Validated example config.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Tuple of (list of provisioning results, optional error).
|
|
88
|
+
"""
|
|
89
|
+
results: List[ProvisioningResult] = []
|
|
90
|
+
self.id_map = {} # Reset id_map for each provision run
|
|
91
|
+
|
|
92
|
+
resources = config.get("resources", [])
|
|
93
|
+
if not isinstance(resources, list):
|
|
94
|
+
return [], ValueError("Config 'resources' must be a list")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
for resource in resources:
|
|
98
|
+
if not isinstance(resource, dict):
|
|
99
|
+
res = ProvisioningResult(
|
|
100
|
+
id_reference=str(resource),
|
|
101
|
+
resource_type="unknown",
|
|
102
|
+
resource_name="unknown",
|
|
103
|
+
action=ProvisioningAction.FAILED,
|
|
104
|
+
error="Resource definition must be a dict",
|
|
105
|
+
)
|
|
106
|
+
results.append(res)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
res = self._provision_resource(resource, self.id_map)
|
|
110
|
+
results.append(res)
|
|
111
|
+
|
|
112
|
+
# Record server_id for reference substitution in subsequent resources
|
|
113
|
+
if res.action == ProvisioningAction.CREATED and res.server_id:
|
|
114
|
+
self.id_map[res.id_reference] = res.server_id
|
|
115
|
+
elif res.action == ProvisioningAction.SKIPPED:
|
|
116
|
+
# Use actual server_id if available, otherwise use dryrun marker
|
|
117
|
+
if res.server_id:
|
|
118
|
+
self.id_map[res.id_reference] = res.server_id
|
|
119
|
+
else:
|
|
120
|
+
# Even in dry-run, populate a predictable simulated ID to enable
|
|
121
|
+
# reference substitution demonstrations in logs/tests.
|
|
122
|
+
self.id_map[res.id_reference] = f"dryrun-{res.id_reference}"
|
|
123
|
+
|
|
124
|
+
return results, None
|
|
125
|
+
except Exception as exc: # pragma: no cover - defensive catch
|
|
126
|
+
return results, exc
|
|
127
|
+
|
|
128
|
+
def delete(
|
|
129
|
+
self, config: Dict[str, Any], filter_tags: Optional[List[str]] = None
|
|
130
|
+
) -> Tuple[List[ProvisioningResult], Optional[Exception]]:
|
|
131
|
+
"""Delete all resources defined in the provided config.
|
|
132
|
+
|
|
133
|
+
Deletes in reverse provisioning order (last created, first deleted).
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
config: Validated example config.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Tuple of (list of deletion results, optional error).
|
|
140
|
+
"""
|
|
141
|
+
results: List[ProvisioningResult] = []
|
|
142
|
+
|
|
143
|
+
resources = config.get("resources", [])
|
|
144
|
+
if not isinstance(resources, list):
|
|
145
|
+
return [], ValueError("Config 'resources' must be a list")
|
|
146
|
+
|
|
147
|
+
# Reset per-run flags
|
|
148
|
+
self._test_results_deleted = False
|
|
149
|
+
self._files_deleted = False
|
|
150
|
+
self._notebooks_deleted = False
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
for resource in reversed([r for r in resources if isinstance(r, dict)]):
|
|
154
|
+
rtype = str(resource.get("type", "unknown"))
|
|
155
|
+
rname = str(resource.get("name", "unknown"))
|
|
156
|
+
rid = str(resource.get("id_reference", rname or rtype))
|
|
157
|
+
rtags = resource.get("tags", [])
|
|
158
|
+
if not isinstance(rtags, list):
|
|
159
|
+
rtags = []
|
|
160
|
+
|
|
161
|
+
# Apply tag filter: skip resources that do not match filter tags
|
|
162
|
+
if filter_tags:
|
|
163
|
+
matches = any(tag in rtags for tag in filter_tags)
|
|
164
|
+
if not matches:
|
|
165
|
+
results.append(
|
|
166
|
+
ProvisioningResult(
|
|
167
|
+
id_reference=rid,
|
|
168
|
+
resource_type=rtype,
|
|
169
|
+
resource_name=rname,
|
|
170
|
+
action=ProvisioningAction.SKIPPED,
|
|
171
|
+
error="tag-filter",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if self.dry_run:
|
|
177
|
+
results.append(
|
|
178
|
+
ProvisioningResult(
|
|
179
|
+
id_reference=rid,
|
|
180
|
+
resource_type=rtype,
|
|
181
|
+
resource_name=rname,
|
|
182
|
+
action=ProvisioningAction.SKIPPED,
|
|
183
|
+
server_id=None,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# Dispatch to delete method
|
|
189
|
+
delete_map = {
|
|
190
|
+
"location": self._delete_location,
|
|
191
|
+
"product": self._delete_product,
|
|
192
|
+
"system": self._delete_system,
|
|
193
|
+
"asset": self._delete_asset,
|
|
194
|
+
"dut": self._delete_dut,
|
|
195
|
+
"testtemplate": self._delete_testtemplate,
|
|
196
|
+
"workflow": self._delete_workflow,
|
|
197
|
+
"work_item": self._delete_work_item,
|
|
198
|
+
"work_order": self._delete_work_order,
|
|
199
|
+
"test_result": self._delete_test_result,
|
|
200
|
+
"data_table": self._delete_data_table,
|
|
201
|
+
"file": self._delete_file,
|
|
202
|
+
"notebook": self._delete_notebook,
|
|
203
|
+
}
|
|
204
|
+
delete_fn = delete_map.get(rtype)
|
|
205
|
+
if not delete_fn:
|
|
206
|
+
results.append(
|
|
207
|
+
ProvisioningResult(
|
|
208
|
+
id_reference=rid,
|
|
209
|
+
resource_type=rtype,
|
|
210
|
+
resource_name=rname,
|
|
211
|
+
action=ProvisioningAction.FAILED,
|
|
212
|
+
error=f"Unsupported resource type: {rtype}",
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
server_id = delete_fn({"name": rname})
|
|
218
|
+
# Determine action: DELETED if successful, SKIPPED if not found
|
|
219
|
+
action = ProvisioningAction.DELETED if server_id else ProvisioningAction.SKIPPED
|
|
220
|
+
results.append(
|
|
221
|
+
ProvisioningResult(
|
|
222
|
+
id_reference=rid,
|
|
223
|
+
resource_type=rtype,
|
|
224
|
+
resource_name=rname,
|
|
225
|
+
action=action,
|
|
226
|
+
server_id=server_id,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return results, None
|
|
231
|
+
except Exception as exc: # pragma: no cover - defensive catch
|
|
232
|
+
return results, exc
|
|
233
|
+
|
|
234
|
+
def _provision_resource(
|
|
235
|
+
self, resource_def: Dict[str, Any], id_map: Dict[str, str]
|
|
236
|
+
) -> ProvisioningResult:
|
|
237
|
+
"""Provision a single resource.
|
|
238
|
+
|
|
239
|
+
Substitutes ${ref} in properties using id_map built from previous creations.
|
|
240
|
+
"""
|
|
241
|
+
rtype = str(resource_def.get("type", "unknown"))
|
|
242
|
+
rname = str(resource_def.get("name", "unknown"))
|
|
243
|
+
rid = str(resource_def.get("id_reference", rname or rtype))
|
|
244
|
+
properties = resource_def.get("properties", {})
|
|
245
|
+
|
|
246
|
+
# Substitute ${ref} tokens in properties with server IDs
|
|
247
|
+
props_sub = self._resolve_props(properties, id_map)
|
|
248
|
+
|
|
249
|
+
if self.dry_run:
|
|
250
|
+
return ProvisioningResult(
|
|
251
|
+
id_reference=rid,
|
|
252
|
+
resource_type=rtype,
|
|
253
|
+
resource_name=rname,
|
|
254
|
+
action=ProvisioningAction.SKIPPED,
|
|
255
|
+
server_id=None,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Dispatch to create method
|
|
259
|
+
create_map = {
|
|
260
|
+
"location": self._create_location,
|
|
261
|
+
"product": self._create_product,
|
|
262
|
+
"system": self._create_system,
|
|
263
|
+
"asset": self._create_asset,
|
|
264
|
+
"dut": self._create_dut,
|
|
265
|
+
"testtemplate": self._create_testtemplate,
|
|
266
|
+
"workflow": self._create_workflow,
|
|
267
|
+
"work_item": self._create_work_item,
|
|
268
|
+
"work_order": self._create_work_order,
|
|
269
|
+
"test_result": self._create_test_result,
|
|
270
|
+
"data_table": self._create_data_table,
|
|
271
|
+
"file": self._create_file,
|
|
272
|
+
"notebook": self._create_notebook,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
create_fn = create_map.get(rtype)
|
|
276
|
+
if not create_fn:
|
|
277
|
+
return ProvisioningResult(
|
|
278
|
+
id_reference=rid,
|
|
279
|
+
resource_type=rtype,
|
|
280
|
+
resource_name=rname,
|
|
281
|
+
action=ProvisioningAction.FAILED,
|
|
282
|
+
error=f"Unsupported resource type: {rtype}",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
# Add name to props for creation
|
|
287
|
+
props_with_name = dict(props_sub)
|
|
288
|
+
props_with_name["name"] = rname
|
|
289
|
+
|
|
290
|
+
# Check if resource already exists to avoid duplicates
|
|
291
|
+
existing_id = None
|
|
292
|
+
if rtype == "location":
|
|
293
|
+
existing_id = self._get_location_by_name(rname)
|
|
294
|
+
elif rtype == "product":
|
|
295
|
+
existing_id = self._get_product_by_name(rname)
|
|
296
|
+
elif rtype == "system":
|
|
297
|
+
existing_id = self._get_system_by_name(rname)
|
|
298
|
+
elif rtype == "asset":
|
|
299
|
+
existing_id = self._get_asset_by_name(rname)
|
|
300
|
+
elif rtype == "dut":
|
|
301
|
+
existing_id = self._get_dut_by_name(rname)
|
|
302
|
+
elif rtype == "testtemplate":
|
|
303
|
+
existing_id = self._get_testtemplate_by_name(rname)
|
|
304
|
+
elif rtype == "workflow":
|
|
305
|
+
existing_id = self._get_workflow_by_name(rname)
|
|
306
|
+
elif rtype == "work_item":
|
|
307
|
+
existing_id = self._get_work_item_by_name(rname)
|
|
308
|
+
elif rtype == "work_order":
|
|
309
|
+
existing_id = self._get_work_order_by_name(rname)
|
|
310
|
+
elif rtype == "test_result":
|
|
311
|
+
existing_id = self._get_test_result_by_name(rname)
|
|
312
|
+
elif rtype == "data_table":
|
|
313
|
+
existing_id = self._get_data_table_by_name(rname)
|
|
314
|
+
elif rtype == "file":
|
|
315
|
+
existing_id = self._get_file_by_name(rname)
|
|
316
|
+
|
|
317
|
+
if existing_id:
|
|
318
|
+
# Resource already exists, skip creation
|
|
319
|
+
return ProvisioningResult(
|
|
320
|
+
id_reference=rid,
|
|
321
|
+
resource_type=rtype,
|
|
322
|
+
resource_name=rname,
|
|
323
|
+
action=ProvisioningAction.SKIPPED,
|
|
324
|
+
server_id=existing_id,
|
|
325
|
+
error="Resource already exists",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
server_id = create_fn(props_with_name)
|
|
329
|
+
# Check for duplicate marker from create functions
|
|
330
|
+
if server_id and server_id.startswith("__DUPLICATE_ID__"):
|
|
331
|
+
# Extract actual ID from marker (e.g., "__DUPLICATE_ID__<uuid>" -> "<uuid>")
|
|
332
|
+
actual_id = server_id.replace("__DUPLICATE_ID__", "", 1)
|
|
333
|
+
return ProvisioningResult(
|
|
334
|
+
id_reference=rid,
|
|
335
|
+
resource_type=rtype,
|
|
336
|
+
resource_name=rname,
|
|
337
|
+
action=ProvisioningAction.SKIPPED,
|
|
338
|
+
server_id=actual_id,
|
|
339
|
+
error="Resource already exists (duplicate)",
|
|
340
|
+
)
|
|
341
|
+
elif server_id and server_id.startswith("__DUPLICATE__"):
|
|
342
|
+
# Duplicate detected but ID not found
|
|
343
|
+
return ProvisioningResult(
|
|
344
|
+
id_reference=rid,
|
|
345
|
+
resource_type=rtype,
|
|
346
|
+
resource_name=rname,
|
|
347
|
+
action=ProvisioningAction.SKIPPED,
|
|
348
|
+
server_id=None,
|
|
349
|
+
error="Resource already exists (duplicate)",
|
|
350
|
+
)
|
|
351
|
+
# Only mark as CREATED if server_id is valid
|
|
352
|
+
if server_id:
|
|
353
|
+
return ProvisioningResult(
|
|
354
|
+
id_reference=rid,
|
|
355
|
+
resource_type=rtype,
|
|
356
|
+
resource_name=rname,
|
|
357
|
+
action=ProvisioningAction.CREATED,
|
|
358
|
+
server_id=server_id,
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
# Creation returned no valid ID - could be duplicate or actual failure
|
|
362
|
+
return ProvisioningResult(
|
|
363
|
+
id_reference=rid,
|
|
364
|
+
resource_type=rtype,
|
|
365
|
+
resource_name=rname,
|
|
366
|
+
action=ProvisioningAction.FAILED,
|
|
367
|
+
server_id=None,
|
|
368
|
+
error="Creation failed: no valid ID returned",
|
|
369
|
+
)
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
# Try to extract error details from response
|
|
372
|
+
error_msg = str(exc)
|
|
373
|
+
if hasattr(exc, "response") and exc.response is not None: # type: ignore
|
|
374
|
+
try:
|
|
375
|
+
error_body = exc.response.json() # type: ignore
|
|
376
|
+
if "error" in error_body:
|
|
377
|
+
error_msg = f"{error_msg}: {error_body['error']}"
|
|
378
|
+
elif "message" in error_body:
|
|
379
|
+
error_msg = f"{error_msg}: {error_body['message']}"
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
return ProvisioningResult(
|
|
384
|
+
id_reference=rid,
|
|
385
|
+
resource_type=rtype,
|
|
386
|
+
resource_name=rname,
|
|
387
|
+
action=ProvisioningAction.FAILED,
|
|
388
|
+
error=error_msg,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def _resolve_props(self, obj: Any, id_map: Dict[str, str]) -> Any:
|
|
392
|
+
"""Resolve ${ref} tokens recursively in a properties object.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
obj: Properties object (dict, list, str, etc.).
|
|
396
|
+
id_map: Map of id_reference to server_id.
|
|
397
|
+
"""
|
|
398
|
+
if isinstance(obj, dict):
|
|
399
|
+
return {k: self._resolve_props(v, id_map) for k, v in obj.items()}
|
|
400
|
+
if isinstance(obj, list):
|
|
401
|
+
return [self._resolve_props(v, id_map) for v in obj]
|
|
402
|
+
if isinstance(obj, str) and obj.startswith("${") and obj.endswith("}"):
|
|
403
|
+
ref = obj[2:-1]
|
|
404
|
+
return id_map.get(ref, obj) # leave as-is if not yet defined
|
|
405
|
+
return obj
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _deduplicate_keywords(keywords: List[str]) -> List[str]:
|
|
409
|
+
"""Return deduplicated keywords preserving insertion order."""
|
|
410
|
+
seen: set[str] = set()
|
|
411
|
+
result = []
|
|
412
|
+
for kw in keywords:
|
|
413
|
+
if kw not in seen:
|
|
414
|
+
result.append(kw)
|
|
415
|
+
seen.add(kw)
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
# --- Create methods (real API calls) ---
|
|
419
|
+
def _create_location(self, props: Dict[str, Any]) -> str:
|
|
420
|
+
"""Create location via /nilocation/v1/locations API and return server ID."""
|
|
421
|
+
url = f"{get_base_url()}/nilocation/v1/locations"
|
|
422
|
+
payload = {"name": props.get("name", "Unknown Location")}
|
|
423
|
+
|
|
424
|
+
# Add workspace if available
|
|
425
|
+
if self.workspace_id:
|
|
426
|
+
payload["workspace"] = self.workspace_id
|
|
427
|
+
|
|
428
|
+
# Copy optional fields from CreateLocationRequest schema
|
|
429
|
+
for key in [
|
|
430
|
+
"type",
|
|
431
|
+
"enabled",
|
|
432
|
+
"description",
|
|
433
|
+
"parentId",
|
|
434
|
+
"scanCode",
|
|
435
|
+
"properties",
|
|
436
|
+
"keywords",
|
|
437
|
+
]:
|
|
438
|
+
if key in props:
|
|
439
|
+
payload[key] = props[key]
|
|
440
|
+
|
|
441
|
+
# Tag resource with example name for cleanup
|
|
442
|
+
if self.example_name:
|
|
443
|
+
keywords = payload.get("keywords", [])
|
|
444
|
+
if not isinstance(keywords, list):
|
|
445
|
+
keywords = []
|
|
446
|
+
keywords.append(f"slcli-example:{self.example_name}")
|
|
447
|
+
payload["keywords"] = keywords
|
|
448
|
+
|
|
449
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
450
|
+
data = resp.json()
|
|
451
|
+
return str(data.get("id", ""))
|
|
452
|
+
|
|
453
|
+
def _get_location_by_name(self, name: str) -> Optional[str]:
|
|
454
|
+
"""Find a location by exact `name`, constrained to this example tag and workspace.
|
|
455
|
+
|
|
456
|
+
The locations API doesn't support filtering or pagination via URL params.
|
|
457
|
+
Instead, request all locations and filter client-side:
|
|
458
|
+
- Match `location['name']` exactly to `name`.
|
|
459
|
+
- If `self.workspace_id` is set, match `location['workspace']`.
|
|
460
|
+
- Ensure `location['keywords']` contains `slcli-example:{self.example_name}`.
|
|
461
|
+
Returns the first matching `id`, or None if not found.
|
|
462
|
+
"""
|
|
463
|
+
try:
|
|
464
|
+
url = f"{get_base_url()}/nilocation/v1/locations"
|
|
465
|
+
resp = make_api_request("GET", url, handle_errors=False)
|
|
466
|
+
data = resp.json()
|
|
467
|
+
locations = data.get("locations", [])
|
|
468
|
+
|
|
469
|
+
example_tag = f"slcli-example:{self.example_name}" if self.example_name else None
|
|
470
|
+
|
|
471
|
+
for loc in locations:
|
|
472
|
+
if str(loc.get("name", "")) != name:
|
|
473
|
+
continue
|
|
474
|
+
if self.workspace_id and str(loc.get("workspace", "")) != str(self.workspace_id):
|
|
475
|
+
continue
|
|
476
|
+
if example_tag:
|
|
477
|
+
keywords = loc.get("keywords", [])
|
|
478
|
+
if not (isinstance(keywords, list) and example_tag in keywords):
|
|
479
|
+
continue
|
|
480
|
+
return str(loc.get("id", "")) or None
|
|
481
|
+
except Exception:
|
|
482
|
+
# API unavailable or malformed response; return None to allow fallback to creation
|
|
483
|
+
pass
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
def _create_product(self, props: Dict[str, Any]) -> str:
|
|
487
|
+
"""Create product via Test Monitor API and return server ID.
|
|
488
|
+
|
|
489
|
+
Uses POST /nitestmonitor/v2/products with request body:
|
|
490
|
+
{ "products": [{ partNumber, name, family, keywords, properties, fileIds, workspace }] }
|
|
491
|
+
"""
|
|
492
|
+
url = f"{get_base_url()}/nitestmonitor/v2/products"
|
|
493
|
+
product_obj = {
|
|
494
|
+
"name": props.get("name", "Unknown Product"),
|
|
495
|
+
"workspace": self.workspace_id or "",
|
|
496
|
+
}
|
|
497
|
+
# Copy optional fields from ProductRequestObject schema
|
|
498
|
+
for key in ["partNumber", "family", "properties"]:
|
|
499
|
+
if key in props:
|
|
500
|
+
product_obj[key] = props[key]
|
|
501
|
+
# Also accept snake_case aliases from config.yaml
|
|
502
|
+
if "partNumber" not in product_obj and "part_number" in props:
|
|
503
|
+
product_obj["partNumber"] = props["part_number"]
|
|
504
|
+
|
|
505
|
+
# Handle fileIds: resolve file references from id_map
|
|
506
|
+
file_ids: List[str] = []
|
|
507
|
+
# Check for fileIds directly in props
|
|
508
|
+
if "fileIds" in props and isinstance(props["fileIds"], list):
|
|
509
|
+
file_ids.extend([str(fid) for fid in props["fileIds"]])
|
|
510
|
+
# Check for file_id_references that need to be resolved
|
|
511
|
+
if "file_id_references" in props and isinstance(props["file_id_references"], list):
|
|
512
|
+
for ref in props["file_id_references"]:
|
|
513
|
+
if ref in self.id_map:
|
|
514
|
+
file_ids.append(self.id_map[ref])
|
|
515
|
+
else:
|
|
516
|
+
click.echo(
|
|
517
|
+
f"Warning: File reference '{ref}' not found in id_map for product {product_obj['name']}",
|
|
518
|
+
err=True,
|
|
519
|
+
)
|
|
520
|
+
# If we have file IDs, add them to the product object
|
|
521
|
+
if file_ids:
|
|
522
|
+
product_obj["fileIds"] = file_ids
|
|
523
|
+
|
|
524
|
+
# Ensure part number is present to avoid silent failures
|
|
525
|
+
if "partNumber" not in product_obj:
|
|
526
|
+
fallback_pn = str(product_obj.get("name", "SLCLI-PRODUCT")).replace(" ", "-")
|
|
527
|
+
product_obj["partNumber"] = fallback_pn
|
|
528
|
+
|
|
529
|
+
# Tag resource for cleanup using keywords
|
|
530
|
+
keywords: List[str] = []
|
|
531
|
+
if isinstance(props.get("keywords"), list):
|
|
532
|
+
keywords.extend([str(x) for x in props.get("keywords", [])])
|
|
533
|
+
if isinstance(props.get("tags"), list):
|
|
534
|
+
keywords.extend([str(x) for x in props.get("tags", [])])
|
|
535
|
+
keywords.append("slcli-provisioner")
|
|
536
|
+
if self.example_name:
|
|
537
|
+
keywords.append(f"slcli-example:{self.example_name}")
|
|
538
|
+
if keywords:
|
|
539
|
+
product_obj["keywords"] = self._deduplicate_keywords(keywords)
|
|
540
|
+
|
|
541
|
+
# Wrap in products array per API schema
|
|
542
|
+
payload = {"products": [product_obj]}
|
|
543
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
544
|
+
data = resp.json()
|
|
545
|
+
# Response is { products: [...], failed: [...], error: {...} }
|
|
546
|
+
# Check for successful creation first
|
|
547
|
+
products = data.get("products", [])
|
|
548
|
+
if products and len(products) > 0:
|
|
549
|
+
return str(products[0].get("id", ""))
|
|
550
|
+
# Check for duplicate part number error
|
|
551
|
+
if data.get("error") and data["error"].get("name") == "Skyline.OneOrMoreErrorsOccurred":
|
|
552
|
+
inner_errors = data["error"].get("innerErrors", [])
|
|
553
|
+
for err in inner_errors:
|
|
554
|
+
if "Duplicate" in err.get("message", ""):
|
|
555
|
+
# Query for existing product by part number
|
|
556
|
+
part_number = product_obj.get("partNumber", "")
|
|
557
|
+
name = product_obj.get("name", "")
|
|
558
|
+
if part_number:
|
|
559
|
+
try:
|
|
560
|
+
base_query_url = f"{get_base_url()}/nitestmonitor/v2/products"
|
|
561
|
+
continuation_token = None
|
|
562
|
+
|
|
563
|
+
# Paginate through all products to find match
|
|
564
|
+
while True:
|
|
565
|
+
query_url = base_query_url
|
|
566
|
+
if continuation_token:
|
|
567
|
+
query_url = (
|
|
568
|
+
f"{base_query_url}?continuationToken={continuation_token}"
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
query_resp = make_api_request("GET", query_url, handle_errors=False)
|
|
572
|
+
query_data = query_resp.json()
|
|
573
|
+
|
|
574
|
+
# Search through products on this page for match by part number
|
|
575
|
+
for prod in query_data.get("products", []):
|
|
576
|
+
if prod.get("partNumber") == part_number:
|
|
577
|
+
prod_id = prod.get("id", "")
|
|
578
|
+
if prod_id:
|
|
579
|
+
# Return with duplicate marker so provisioning
|
|
580
|
+
# knows it's a skip
|
|
581
|
+
return f"__DUPLICATE_ID__{prod_id}"
|
|
582
|
+
|
|
583
|
+
# If not found by part number on this page, try by name as fallback
|
|
584
|
+
if name:
|
|
585
|
+
for prod in query_data.get("products", []):
|
|
586
|
+
if prod.get("name") == name:
|
|
587
|
+
prod_id = prod.get("id", "")
|
|
588
|
+
if prod_id:
|
|
589
|
+
return f"__DUPLICATE_ID__{prod_id}"
|
|
590
|
+
|
|
591
|
+
# Check for continuation token for next page
|
|
592
|
+
continuation_token = query_data.get("continuationToken")
|
|
593
|
+
if not continuation_token:
|
|
594
|
+
# No more pages, duplicate not found
|
|
595
|
+
break
|
|
596
|
+
|
|
597
|
+
# Duplicate detected but ID not found in any page
|
|
598
|
+
return "__DUPLICATE_NOTFOUND__"
|
|
599
|
+
except Exception:
|
|
600
|
+
# Pagination or query error during duplicate detection; treat as unfound
|
|
601
|
+
return "__DUPLICATE_NOTFOUND__"
|
|
602
|
+
return ""
|
|
603
|
+
|
|
604
|
+
def _get_product_by_name(self, name: str) -> Optional[str]:
|
|
605
|
+
"""Find a product by exact `name` within workspace. Returns ID or None.
|
|
606
|
+
|
|
607
|
+
Uses Test Monitor API: GET /nitestmonitor/v2/products which returns { products: [...] }.
|
|
608
|
+
Filters client-side on:
|
|
609
|
+
- `name` equals `name`
|
|
610
|
+
- `workspace` equals `self.workspace_id` (if set)
|
|
611
|
+
- `keywords` contains the example tag (if set)
|
|
612
|
+
"""
|
|
613
|
+
try:
|
|
614
|
+
url = f"{get_base_url()}/nitestmonitor/v2/products"
|
|
615
|
+
resp = make_api_request("GET", url, handle_errors=False)
|
|
616
|
+
data = resp.json()
|
|
617
|
+
products = data.get("products", [])
|
|
618
|
+
example_tag = f"slcli-example:{self.example_name}" if self.example_name else None
|
|
619
|
+
for prod in products:
|
|
620
|
+
if str(prod.get("name", "")) != name:
|
|
621
|
+
continue
|
|
622
|
+
if self.workspace_id and str(prod.get("workspace", "")) != str(self.workspace_id):
|
|
623
|
+
continue
|
|
624
|
+
if example_tag:
|
|
625
|
+
keywords = prod.get("keywords", [])
|
|
626
|
+
if not (isinstance(keywords, list) and example_tag in keywords):
|
|
627
|
+
continue
|
|
628
|
+
return str(prod.get("id", "")) or None
|
|
629
|
+
except Exception:
|
|
630
|
+
# API unavailable or malformed response; return None to allow fallback to creation
|
|
631
|
+
pass
|
|
632
|
+
return None
|
|
633
|
+
|
|
634
|
+
def _create_system(self, props: Dict[str, Any]) -> str:
|
|
635
|
+
"""Create virtual system via Systems Management API and return server ID.
|
|
636
|
+
|
|
637
|
+
Uses POST /nisysmgmt/v1/virtual with request body:
|
|
638
|
+
{ alias, workspace }
|
|
639
|
+
"""
|
|
640
|
+
url = f"{get_base_url()}/nisysmgmt/v1/virtual"
|
|
641
|
+
# Systems Management API uses 'alias' not 'name'
|
|
642
|
+
payload: Dict[str, Any] = {
|
|
643
|
+
"alias": props.get("name", "Unknown System"),
|
|
644
|
+
}
|
|
645
|
+
# Only include workspace if we have a specific workspace ID
|
|
646
|
+
# Note: Systems API rejects empty string workspace
|
|
647
|
+
if self.workspace_id and self.workspace_id.strip():
|
|
648
|
+
payload["workspace"] = self.workspace_id
|
|
649
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
650
|
+
resp.raise_for_status()
|
|
651
|
+
data = resp.json()
|
|
652
|
+
# Response is { minionId }
|
|
653
|
+
return str(data.get("minionId", ""))
|
|
654
|
+
|
|
655
|
+
def _get_system_by_name(self, name: str) -> Optional[str]:
|
|
656
|
+
"""Find a system by exact alias within workspace. Returns first ID or None.
|
|
657
|
+
|
|
658
|
+
Uses Systems Management API: POST /nisysmgmt/v1/query-systems with QuerySystemsRequest.
|
|
659
|
+
Handles both response shapes: { count, data: [...] } and legacy list.
|
|
660
|
+
"""
|
|
661
|
+
try:
|
|
662
|
+
url = f"{get_base_url()}/nisysmgmt/v1/query-systems"
|
|
663
|
+
filter_expr = f'alias = "{name}"'
|
|
664
|
+
payload = {
|
|
665
|
+
"skip": 0,
|
|
666
|
+
"take": 100,
|
|
667
|
+
"filter": filter_expr,
|
|
668
|
+
"projection": "new(id,alias,workspace)",
|
|
669
|
+
"orderBy": "alias",
|
|
670
|
+
}
|
|
671
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
672
|
+
data = resp.json()
|
|
673
|
+
systems: List[Dict[str, Any]] = []
|
|
674
|
+
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
|
675
|
+
systems = data.get("data", [])
|
|
676
|
+
elif isinstance(data, list):
|
|
677
|
+
# Legacy shape: list of items with optional 'data' field
|
|
678
|
+
for item in data:
|
|
679
|
+
sys = item.get("data", item) if isinstance(item, dict) else {}
|
|
680
|
+
if sys:
|
|
681
|
+
systems.append(sys)
|
|
682
|
+
for sys in systems:
|
|
683
|
+
alias = str(sys.get("alias", ""))
|
|
684
|
+
if alias != name:
|
|
685
|
+
continue
|
|
686
|
+
if self.workspace_id and str(sys.get("workspace", "")) != str(self.workspace_id):
|
|
687
|
+
continue
|
|
688
|
+
return str(sys.get("id", "")) or None
|
|
689
|
+
except Exception:
|
|
690
|
+
# API unavailable or malformed response; return None to allow fallback to creation
|
|
691
|
+
pass
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
def _get_system_ids_by_name(self, name: str) -> List[str]:
|
|
695
|
+
"""Return all system IDs matching alias and workspace."""
|
|
696
|
+
ids: List[str] = []
|
|
697
|
+
try:
|
|
698
|
+
url = f"{get_base_url()}/nisysmgmt/v1/query-systems"
|
|
699
|
+
filter_expr = f'alias = "{name}"'
|
|
700
|
+
payload = {
|
|
701
|
+
"skip": 0,
|
|
702
|
+
"take": 200,
|
|
703
|
+
"filter": filter_expr,
|
|
704
|
+
"projection": "new(id,alias,workspace)",
|
|
705
|
+
"orderBy": "alias",
|
|
706
|
+
}
|
|
707
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
708
|
+
data = resp.json()
|
|
709
|
+
systems: List[Dict[str, Any]] = []
|
|
710
|
+
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
|
711
|
+
systems = data.get("data", [])
|
|
712
|
+
elif isinstance(data, list):
|
|
713
|
+
for item in data:
|
|
714
|
+
sys = item.get("data", item) if isinstance(item, dict) else {}
|
|
715
|
+
if sys:
|
|
716
|
+
systems.append(sys)
|
|
717
|
+
for sys in systems:
|
|
718
|
+
alias = str(sys.get("alias", ""))
|
|
719
|
+
if alias != name:
|
|
720
|
+
continue
|
|
721
|
+
if self.workspace_id and str(sys.get("workspace", "")) != str(self.workspace_id):
|
|
722
|
+
continue
|
|
723
|
+
sid = str(sys.get("id", ""))
|
|
724
|
+
if sid:
|
|
725
|
+
ids.append(sid)
|
|
726
|
+
except Exception:
|
|
727
|
+
# API unavailable or malformed response; return empty list to proceed with creation
|
|
728
|
+
pass
|
|
729
|
+
return ids
|
|
730
|
+
|
|
731
|
+
def _build_asset_obj(
|
|
732
|
+
self,
|
|
733
|
+
props: Dict[str, Any],
|
|
734
|
+
default_name: str = "Unknown Asset",
|
|
735
|
+
asset_type: Optional[str] = None,
|
|
736
|
+
) -> Dict[str, Any]:
|
|
737
|
+
"""Build an AssetCreateModel dict from *props*, shared by asset and DUT creation.
|
|
738
|
+
|
|
739
|
+
Handles field-map resolution (snake_case → camelCase), numeric coercion,
|
|
740
|
+
busType normalisation, defaults, description, system_id → location, and
|
|
741
|
+
keyword deduplication.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
props: Resource properties from the config YAML.
|
|
745
|
+
default_name: Fallback name when props lacks ``name``.
|
|
746
|
+
asset_type: Explicit ``assetType`` value (e.g. ``"DEVICE_UNDER_TEST"``).
|
|
747
|
+
When *None*, the field-map resolution decides.
|
|
748
|
+
"""
|
|
749
|
+
asset_obj: Dict[str, Any] = {
|
|
750
|
+
"name": props.get("name", default_name),
|
|
751
|
+
"workspace": self.workspace_id or "",
|
|
752
|
+
}
|
|
753
|
+
if asset_type is not None:
|
|
754
|
+
asset_obj["assetType"] = asset_type
|
|
755
|
+
|
|
756
|
+
# Copy optional fields from AssetCreateModel schema, supporting snake_case inputs
|
|
757
|
+
field_map: Dict[str, List[str]] = {
|
|
758
|
+
"assetType": ["assetType"],
|
|
759
|
+
"busType": ["busType", "bus_type"],
|
|
760
|
+
"modelName": ["modelName", "model_name", "model"],
|
|
761
|
+
"modelNumber": ["modelNumber", "model_number"],
|
|
762
|
+
"vendorName": ["vendorName", "vendor_name"],
|
|
763
|
+
"vendorNumber": ["vendorNumber", "vendor_number"],
|
|
764
|
+
"serialNumber": ["serialNumber", "serial_number"],
|
|
765
|
+
"partNumber": ["partNumber", "part_number"],
|
|
766
|
+
"properties": ["properties"],
|
|
767
|
+
"fileIds": ["fileIds", "file_ids"],
|
|
768
|
+
}
|
|
769
|
+
for target, candidates in field_map.items():
|
|
770
|
+
# If the caller already set asset_type, skip the assetType field-map entry
|
|
771
|
+
if target == "assetType" and asset_type is not None:
|
|
772
|
+
continue
|
|
773
|
+
val = None
|
|
774
|
+
for cand in candidates:
|
|
775
|
+
if cand in props:
|
|
776
|
+
val = props[cand]
|
|
777
|
+
break
|
|
778
|
+
if val is None:
|
|
779
|
+
continue
|
|
780
|
+
# Special handling: skip invalid serial numbers (empty/whitespace/'0')
|
|
781
|
+
if target == "serialNumber" and isinstance(val, str):
|
|
782
|
+
trimmed = val.strip()
|
|
783
|
+
if trimmed == "" or trimmed == "0":
|
|
784
|
+
continue
|
|
785
|
+
# Coerce numeric fields to integers when provided as strings
|
|
786
|
+
if target in ("modelNumber", "vendorNumber"):
|
|
787
|
+
if isinstance(val, str):
|
|
788
|
+
num = val.strip()
|
|
789
|
+
if num.isdigit():
|
|
790
|
+
asset_obj[target] = int(num)
|
|
791
|
+
continue
|
|
792
|
+
# Skip non-numeric vendor/model numbers to avoid 400
|
|
793
|
+
continue
|
|
794
|
+
elif isinstance(val, (int,)):
|
|
795
|
+
asset_obj[target] = val
|
|
796
|
+
continue
|
|
797
|
+
else:
|
|
798
|
+
continue
|
|
799
|
+
# Normalize bus type values to OpenAPI enum
|
|
800
|
+
if target == "busType" and isinstance(val, str):
|
|
801
|
+
bt = val.strip().upper()
|
|
802
|
+
if bt == "ETHERNET":
|
|
803
|
+
bt = "TCP_IP"
|
|
804
|
+
asset_obj[target] = bt
|
|
805
|
+
continue
|
|
806
|
+
asset_obj[target] = val
|
|
807
|
+
|
|
808
|
+
# Pass description directly (no camelCase alias needed)
|
|
809
|
+
if "description" in props:
|
|
810
|
+
asset_obj["description"] = props["description"]
|
|
811
|
+
|
|
812
|
+
# Provide defaults to satisfy identification when missing
|
|
813
|
+
if "busType" not in asset_obj:
|
|
814
|
+
asset_obj["busType"] = "ACCESSORY"
|
|
815
|
+
if "modelName" not in asset_obj:
|
|
816
|
+
asset_obj["modelName"] = "Unknown"
|
|
817
|
+
if "vendorName" not in asset_obj:
|
|
818
|
+
asset_obj["vendorName"] = "Unknown"
|
|
819
|
+
|
|
820
|
+
# If a system is provided via resolved "system_id", construct the location object
|
|
821
|
+
# using the system's minion ID per AssetLocationWithPresenceModel.
|
|
822
|
+
if "system_id" in props and isinstance(props["system_id"], str):
|
|
823
|
+
asset_obj["location"] = {
|
|
824
|
+
"minionId": props["system_id"],
|
|
825
|
+
"state": {"assetPresence": "UNKNOWN"},
|
|
826
|
+
}
|
|
827
|
+
elif "location" not in asset_obj:
|
|
828
|
+
asset_obj["location"] = {"state": {"assetPresence": "UNKNOWN"}}
|
|
829
|
+
|
|
830
|
+
# Tag resource with example name for cleanup
|
|
831
|
+
keywords: List[str] = []
|
|
832
|
+
if isinstance(props.get("keywords"), list):
|
|
833
|
+
keywords.extend([str(x) for x in props["keywords"]])
|
|
834
|
+
if isinstance(props.get("tags"), list):
|
|
835
|
+
keywords.extend([str(x) for x in props["tags"]])
|
|
836
|
+
keywords.append("slcli-provisioner")
|
|
837
|
+
if self.example_name:
|
|
838
|
+
keywords.append(f"slcli-example:{self.example_name}")
|
|
839
|
+
if keywords:
|
|
840
|
+
asset_obj["keywords"] = self._deduplicate_keywords(keywords)
|
|
841
|
+
|
|
842
|
+
return asset_obj
|
|
843
|
+
|
|
844
|
+
def _post_asset(self, asset_obj: Dict[str, Any]) -> str:
|
|
845
|
+
"""POST an asset to /niapm/v1/assets and return the server ID."""
|
|
846
|
+
url = f"{get_base_url()}/niapm/v1/assets"
|
|
847
|
+
payload = {"assets": [asset_obj]}
|
|
848
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
849
|
+
data = resp.json()
|
|
850
|
+
# Response is { assets: [...], failed: [...], error: {...} }
|
|
851
|
+
# Check for successful creation first
|
|
852
|
+
assets = data.get("assets", [])
|
|
853
|
+
if assets and len(assets) > 0:
|
|
854
|
+
# Prefer 'id', fallback to 'assetIdentifier' if provided
|
|
855
|
+
aid = assets[0].get("id") or assets[0].get("assetIdentifier") or ""
|
|
856
|
+
return str(aid)
|
|
857
|
+
# Check for already exists error - extract ID from error response
|
|
858
|
+
if data.get("error") and data["error"].get("name") == "Skyline.OneOrMoreErrorsOccurred":
|
|
859
|
+
inner_errors = data["error"].get("innerErrors", [])
|
|
860
|
+
for err in inner_errors:
|
|
861
|
+
error_msg = err.get("message", "")
|
|
862
|
+
if "already exists" in error_msg.lower():
|
|
863
|
+
# Extract asset ID from resourceId field
|
|
864
|
+
resource_id = err.get("resourceId")
|
|
865
|
+
if resource_id:
|
|
866
|
+
return str(resource_id)
|
|
867
|
+
return ""
|
|
868
|
+
|
|
869
|
+
def _create_asset(self, props: Dict[str, Any]) -> str:
|
|
870
|
+
"""Create asset via Asset Management API and return server ID.
|
|
871
|
+
|
|
872
|
+
Uses POST /niapm/v1/assets with request body:
|
|
873
|
+
{ "assets": [{ name, assetType, busType, modelName, vendorName, serialNumber,
|
|
874
|
+
workspace, keywords, properties, ... }] }
|
|
875
|
+
"""
|
|
876
|
+
asset_obj = self._build_asset_obj(props, default_name="Unknown Asset")
|
|
877
|
+
return self._post_asset(asset_obj)
|
|
878
|
+
|
|
879
|
+
def _get_asset_by_name(self, name: str) -> Optional[str]:
|
|
880
|
+
"""Find an asset by exact `name` within workspace. Returns ID or None.
|
|
881
|
+
|
|
882
|
+
Uses Asset Management API: POST /niapm/v1/query-assets which returns
|
|
883
|
+
{ assets: [...], totalCount }.
|
|
884
|
+
Filters via API on workspace/name and client-side on example tag (keywords).
|
|
885
|
+
"""
|
|
886
|
+
try:
|
|
887
|
+
url = f"{get_base_url()}/niapm/v1/query-assets"
|
|
888
|
+
filters = []
|
|
889
|
+
if self.workspace_id:
|
|
890
|
+
filters.append(f'Workspace = "{self.workspace_id}"')
|
|
891
|
+
filters.append(f'AssetName = "{name}"')
|
|
892
|
+
filter_expr = " and ".join(filters)
|
|
893
|
+
projection = (
|
|
894
|
+
"new(id,name,modelName,modelNumber,vendorName,vendorNumber,serialNumber,"
|
|
895
|
+
"workspace,properties,keywords,location.minionId,location.parent,"
|
|
896
|
+
"location.physicalLocation,location.state.assetPresence,location.state.systemConnection,"
|
|
897
|
+
"discoveryType,supportsSelfTest,supportsSelfCalibration,supportsReset,"
|
|
898
|
+
"supportsExternalCalibration,scanCode,temperatureSensors.reading,"
|
|
899
|
+
"externalCalibration.resolvedDueDate,selfCalibration.date)"
|
|
900
|
+
)
|
|
901
|
+
payload = {
|
|
902
|
+
"filter": filter_expr,
|
|
903
|
+
"take": 1000,
|
|
904
|
+
"skip": 0,
|
|
905
|
+
"projection": projection,
|
|
906
|
+
}
|
|
907
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
908
|
+
data = resp.json()
|
|
909
|
+
assets = data.get("assets", [])
|
|
910
|
+
example_tag = f"slcli-example:{self.example_name}" if self.example_name else None
|
|
911
|
+
for asset in assets:
|
|
912
|
+
if example_tag:
|
|
913
|
+
keywords = asset.get("keywords", [])
|
|
914
|
+
if not (isinstance(keywords, list) and example_tag in keywords):
|
|
915
|
+
continue
|
|
916
|
+
return str(asset.get("id", "")) or None
|
|
917
|
+
except Exception:
|
|
918
|
+
# API unavailable or malformed response; return None to allow fallback to creation
|
|
919
|
+
pass
|
|
920
|
+
return None
|
|
921
|
+
|
|
922
|
+
def _create_dut(self, props: Dict[str, Any]) -> str:
|
|
923
|
+
"""Create DUT via Asset Management API and return server ID.
|
|
924
|
+
|
|
925
|
+
DUTs are assets with assetType=DEVICE_UNDER_TEST.
|
|
926
|
+
Uses POST /niapm/v1/assets with request body:
|
|
927
|
+
{ "assets": [{ name, assetType: "DEVICE_UNDER_TEST", ... }] }
|
|
928
|
+
"""
|
|
929
|
+
asset_obj = self._build_asset_obj(
|
|
930
|
+
props, default_name="Unknown DUT", asset_type="DEVICE_UNDER_TEST"
|
|
931
|
+
)
|
|
932
|
+
return self._post_asset(asset_obj)
|
|
933
|
+
|
|
934
|
+
def _get_dut_by_name(self, name: str) -> Optional[str]:
|
|
935
|
+
"""Find a DUT by exact `name` within workspace. Returns ID or None.
|
|
936
|
+
|
|
937
|
+
DUTs are managed as assets via Asset Management API: POST /niapm/v1/query-assets
|
|
938
|
+
which returns { assets: [...], totalCount }.
|
|
939
|
+
Filters via API on workspace/name and client-side on example tag (keywords).
|
|
940
|
+
"""
|
|
941
|
+
try:
|
|
942
|
+
url = f"{get_base_url()}/niapm/v1/query-assets"
|
|
943
|
+
filters = []
|
|
944
|
+
if self.workspace_id:
|
|
945
|
+
filters.append(f'Workspace = "{self.workspace_id}"')
|
|
946
|
+
filters.append(f'AssetName = "{name}"')
|
|
947
|
+
filter_expr = " and ".join(filters)
|
|
948
|
+
projection = (
|
|
949
|
+
"new(id,name,modelName,modelNumber,vendorName,vendorNumber,serialNumber,"
|
|
950
|
+
"workspace,properties,keywords,location.minionId,location.parent,"
|
|
951
|
+
"location.physicalLocation,location.state.assetPresence,location.state.systemConnection,"
|
|
952
|
+
"discoveryType,supportsSelfTest,supportsSelfCalibration,supportsReset,"
|
|
953
|
+
"supportsExternalCalibration,scanCode,temperatureSensors.reading,"
|
|
954
|
+
"externalCalibration.resolvedDueDate,selfCalibration.date)"
|
|
955
|
+
)
|
|
956
|
+
payload = {
|
|
957
|
+
"filter": filter_expr,
|
|
958
|
+
"take": 1000,
|
|
959
|
+
"skip": 0,
|
|
960
|
+
"projection": projection,
|
|
961
|
+
}
|
|
962
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
963
|
+
data = resp.json()
|
|
964
|
+
assets = data.get("assets", [])
|
|
965
|
+
example_tag = f"slcli-example:{self.example_name}" if self.example_name else None
|
|
966
|
+
for asset in assets:
|
|
967
|
+
if example_tag:
|
|
968
|
+
keywords = asset.get("keywords", [])
|
|
969
|
+
if not (isinstance(keywords, list) and example_tag in keywords):
|
|
970
|
+
continue
|
|
971
|
+
return str(asset.get("id", "")) or None
|
|
972
|
+
except Exception:
|
|
973
|
+
# API unavailable or malformed response; return None to allow fallback to creation
|
|
974
|
+
pass
|
|
975
|
+
return None
|
|
976
|
+
|
|
977
|
+
def _create_testtemplate(self, props: Dict[str, Any]) -> Optional[str]:
|
|
978
|
+
"""Create work item template via Work Item API and return server ID.
|
|
979
|
+
|
|
980
|
+
Uses POST /niworkitem/v1/workitem-templates with request body:
|
|
981
|
+
{ "workItemTemplates": [{ name, templateGroup, type, workspace, ... }] }
|
|
982
|
+
Required fields: name, templateGroup, type
|
|
983
|
+
"""
|
|
984
|
+
url = f"{get_base_url()}/niworkitem/v1/workitem-templates"
|
|
985
|
+
template_obj = {
|
|
986
|
+
"name": props.get("name", "Unknown Test Template"),
|
|
987
|
+
"templateGroup": props.get("templateGroup", "Default"),
|
|
988
|
+
"type": props.get("type", "testplan"),
|
|
989
|
+
}
|
|
990
|
+
# Only include workspace if we have a specific workspace ID
|
|
991
|
+
if self.workspace_id and self.workspace_id.strip():
|
|
992
|
+
template_obj["workspace"] = self.workspace_id
|
|
993
|
+
# Copy optional fields from CreateWorkItemTemplateRequest schema
|
|
994
|
+
for key in [
|
|
995
|
+
"summary",
|
|
996
|
+
"description",
|
|
997
|
+
"testProgram",
|
|
998
|
+
"productFamilies",
|
|
999
|
+
"partNumbers",
|
|
1000
|
+
"properties",
|
|
1001
|
+
"fileIds",
|
|
1002
|
+
]:
|
|
1003
|
+
if key in props:
|
|
1004
|
+
template_obj[key] = props[key]
|
|
1005
|
+
|
|
1006
|
+
# Note: Work item templates don't support keywords field
|
|
1007
|
+
# To aid cleanup, embed example tag into properties under a reserved key
|
|
1008
|
+
if self.example_name:
|
|
1009
|
+
props_key = template_obj.get("properties") or {}
|
|
1010
|
+
if not isinstance(props_key, dict):
|
|
1011
|
+
props_key = {}
|
|
1012
|
+
props_key.setdefault("slcliExample", str(self.example_name))
|
|
1013
|
+
template_obj["properties"] = props_key
|
|
1014
|
+
|
|
1015
|
+
# Wrap in workItemTemplates array per API schema
|
|
1016
|
+
payload = {"workItemTemplates": [template_obj]}
|
|
1017
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1018
|
+
data = resp.json()
|
|
1019
|
+
# Response is { createdWorkItemTemplates: [...] }
|
|
1020
|
+
templates = data.get("createdWorkItemTemplates", [])
|
|
1021
|
+
if templates and len(templates) > 0:
|
|
1022
|
+
tmpl_id = templates[0].get("id")
|
|
1023
|
+
# Return None if ID is missing, empty, or invalid
|
|
1024
|
+
if tmpl_id and str(tmpl_id).strip():
|
|
1025
|
+
return str(tmpl_id)
|
|
1026
|
+
return None
|
|
1027
|
+
|
|
1028
|
+
def _get_testtemplate_by_name(self, name: str) -> Optional[str]:
|
|
1029
|
+
"""Find a test template by exact `name` within workspace. Returns ID or None.
|
|
1030
|
+
|
|
1031
|
+
Uses Work Item API: POST /niworkitem/v1/query-workitem-templates which returns
|
|
1032
|
+
{ workItemTemplates: [...] }.
|
|
1033
|
+
Filters client-side on:
|
|
1034
|
+
- `name` equals `name`
|
|
1035
|
+
- `workspace` equals `self.workspace_id` (if set)
|
|
1036
|
+
Note: Work item templates don't have keywords field for example tagging.
|
|
1037
|
+
"""
|
|
1038
|
+
try:
|
|
1039
|
+
url = f"{get_base_url()}/niworkitem/v1/query-workitem-templates"
|
|
1040
|
+
resp = make_api_request("POST", url, {}, handle_errors=False)
|
|
1041
|
+
data = resp.json()
|
|
1042
|
+
templates = data.get("workItemTemplates", [])
|
|
1043
|
+
for tmpl in templates:
|
|
1044
|
+
if str(tmpl.get("name", "")) != name:
|
|
1045
|
+
continue
|
|
1046
|
+
if self.workspace_id and str(tmpl.get("workspace", "")) != str(self.workspace_id):
|
|
1047
|
+
continue
|
|
1048
|
+
return str(tmpl.get("id", "")) or None
|
|
1049
|
+
except Exception:
|
|
1050
|
+
# API unavailable or malformed response; return None to allow fallback to creation
|
|
1051
|
+
pass
|
|
1052
|
+
return None
|
|
1053
|
+
|
|
1054
|
+
# --- Delete methods ---
|
|
1055
|
+
def _delete_location(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1056
|
+
"""Delete location via /nilocation/v1/locations:deleteMany API.
|
|
1057
|
+
|
|
1058
|
+
Returns the location ID if deletion succeeded, None otherwise.
|
|
1059
|
+
"""
|
|
1060
|
+
name = props.get("name", "")
|
|
1061
|
+
if not name:
|
|
1062
|
+
return None
|
|
1063
|
+
|
|
1064
|
+
location_id = self._get_location_by_name(name)
|
|
1065
|
+
if not location_id:
|
|
1066
|
+
# Location doesn't exist, nothing to delete
|
|
1067
|
+
return None
|
|
1068
|
+
|
|
1069
|
+
try:
|
|
1070
|
+
url = f"{get_base_url()}/nilocation/v1/locations:deleteMany"
|
|
1071
|
+
payload = {"locationIds": [location_id]}
|
|
1072
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1073
|
+
resp.raise_for_status()
|
|
1074
|
+
return location_id
|
|
1075
|
+
except Exception:
|
|
1076
|
+
return None
|
|
1077
|
+
|
|
1078
|
+
def _delete_product(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1079
|
+
"""Delete products via /nitestmonitor/v2/delete-products using keyword tags.
|
|
1080
|
+
|
|
1081
|
+
Returns an ID summary if deleted, None otherwise.
|
|
1082
|
+
"""
|
|
1083
|
+
example_tag = f"slcli-example:{self.example_name}" if self.example_name else None
|
|
1084
|
+
|
|
1085
|
+
try:
|
|
1086
|
+
# Build filter to match products tagged for cleanup
|
|
1087
|
+
filter_parts = ['keywords.Any(x => x == "slcli-provisioner")']
|
|
1088
|
+
if example_tag:
|
|
1089
|
+
filter_parts.append(f'keywords.Any(x => x == "{example_tag}")')
|
|
1090
|
+
if self.workspace_id:
|
|
1091
|
+
filter_parts.append(f'workspace == "{self.workspace_id}"')
|
|
1092
|
+
|
|
1093
|
+
filter_expr = " && ".join(filter_parts)
|
|
1094
|
+
|
|
1095
|
+
query_url = f"{get_base_url()}/nitestmonitor/v2/query-products"
|
|
1096
|
+
query_payload = {"filter": filter_expr, "take": 1000}
|
|
1097
|
+
query_resp = make_api_request("POST", query_url, query_payload, handle_errors=False)
|
|
1098
|
+
products = query_resp.json().get("products", [])
|
|
1099
|
+
|
|
1100
|
+
product_ids: List[str] = []
|
|
1101
|
+
for prod in products:
|
|
1102
|
+
pid = prod.get("id")
|
|
1103
|
+
if pid:
|
|
1104
|
+
product_ids.append(str(pid))
|
|
1105
|
+
|
|
1106
|
+
if not product_ids:
|
|
1107
|
+
return None
|
|
1108
|
+
|
|
1109
|
+
delete_url = f"{get_base_url()}/nitestmonitor/v2/delete-products"
|
|
1110
|
+
delete_payload = {"ids": product_ids}
|
|
1111
|
+
make_api_request("POST", delete_url, delete_payload, handle_errors=False)
|
|
1112
|
+
|
|
1113
|
+
if len(product_ids) == 1:
|
|
1114
|
+
return product_ids[0]
|
|
1115
|
+
return f"{product_ids[0]} (+{len(product_ids) - 1} more)"
|
|
1116
|
+
except Exception:
|
|
1117
|
+
return None
|
|
1118
|
+
|
|
1119
|
+
def _delete_system(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1120
|
+
"""Delete system via /nisysmgmt/v1/remove-systems.
|
|
1121
|
+
|
|
1122
|
+
Returns ID if deleted, None otherwise.
|
|
1123
|
+
"""
|
|
1124
|
+
name = props.get("name", "")
|
|
1125
|
+
if not name:
|
|
1126
|
+
return None
|
|
1127
|
+
|
|
1128
|
+
system_ids = self._get_system_ids_by_name(name)
|
|
1129
|
+
if not system_ids:
|
|
1130
|
+
return None
|
|
1131
|
+
|
|
1132
|
+
try:
|
|
1133
|
+
url = f"{get_base_url()}/nisysmgmt/v1/remove-systems"
|
|
1134
|
+
payload = {"tgt": system_ids, "force": True}
|
|
1135
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1136
|
+
resp.raise_for_status()
|
|
1137
|
+
# Return the first deleted ID for audit purposes
|
|
1138
|
+
return system_ids[0]
|
|
1139
|
+
except Exception:
|
|
1140
|
+
return None
|
|
1141
|
+
|
|
1142
|
+
def _delete_asset(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1143
|
+
"""Delete asset via /niapm/v1/delete-assets.
|
|
1144
|
+
|
|
1145
|
+
Returns ID if deleted, None otherwise.
|
|
1146
|
+
"""
|
|
1147
|
+
name = props.get("name", "")
|
|
1148
|
+
if not name:
|
|
1149
|
+
return None
|
|
1150
|
+
|
|
1151
|
+
asset_id = self._get_asset_by_name(name)
|
|
1152
|
+
if not asset_id:
|
|
1153
|
+
# Asset doesn't exist
|
|
1154
|
+
return None
|
|
1155
|
+
|
|
1156
|
+
try:
|
|
1157
|
+
url = f"{get_base_url()}/niapm/v1/delete-assets"
|
|
1158
|
+
payload = {"ids": [asset_id]}
|
|
1159
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1160
|
+
resp.raise_for_status()
|
|
1161
|
+
return asset_id
|
|
1162
|
+
except Exception:
|
|
1163
|
+
return None
|
|
1164
|
+
|
|
1165
|
+
def _delete_dut(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1166
|
+
"""Delete DUT via /niapm/v1/delete-assets.
|
|
1167
|
+
|
|
1168
|
+
Returns ID if deleted, None otherwise.
|
|
1169
|
+
"""
|
|
1170
|
+
name = props.get("name", "")
|
|
1171
|
+
if not name:
|
|
1172
|
+
return None
|
|
1173
|
+
|
|
1174
|
+
dut_id = self._get_dut_by_name(name)
|
|
1175
|
+
if not dut_id:
|
|
1176
|
+
# DUT doesn't exist
|
|
1177
|
+
return None
|
|
1178
|
+
|
|
1179
|
+
try:
|
|
1180
|
+
url = f"{get_base_url()}/niapm/v1/delete-assets"
|
|
1181
|
+
payload = {"ids": [dut_id]}
|
|
1182
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1183
|
+
resp.raise_for_status()
|
|
1184
|
+
return dut_id
|
|
1185
|
+
except Exception:
|
|
1186
|
+
return None
|
|
1187
|
+
|
|
1188
|
+
def _delete_testtemplate(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1189
|
+
"""Delete test template via /niworkitem/v1/delete-workitem-templates.
|
|
1190
|
+
|
|
1191
|
+
Returns ID if deleted, None otherwise.
|
|
1192
|
+
"""
|
|
1193
|
+
name = props.get("name", "")
|
|
1194
|
+
if not name:
|
|
1195
|
+
return None
|
|
1196
|
+
|
|
1197
|
+
template_id = self._get_testtemplate_by_name(name)
|
|
1198
|
+
if not template_id:
|
|
1199
|
+
# Template doesn't exist
|
|
1200
|
+
return None
|
|
1201
|
+
|
|
1202
|
+
try:
|
|
1203
|
+
url = f"{get_base_url()}/niworkitem/v1/delete-workitem-templates"
|
|
1204
|
+
payload = {"ids": [template_id]}
|
|
1205
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1206
|
+
resp.raise_for_status()
|
|
1207
|
+
return template_id
|
|
1208
|
+
except Exception:
|
|
1209
|
+
return None
|
|
1210
|
+
|
|
1211
|
+
# ========================================================================
|
|
1212
|
+
# Workflow Methods (Tier 2)
|
|
1213
|
+
# ========================================================================
|
|
1214
|
+
|
|
1215
|
+
def _create_workflow(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1216
|
+
"""Create workflow via /niworkorder/v1/workflows.
|
|
1217
|
+
|
|
1218
|
+
Returns workflow ID if created, None on error.
|
|
1219
|
+
"""
|
|
1220
|
+
name = props.get("name", "")
|
|
1221
|
+
if not name:
|
|
1222
|
+
return None
|
|
1223
|
+
|
|
1224
|
+
try:
|
|
1225
|
+
# Use the same schema as workflows init/import command
|
|
1226
|
+
# Note: keywords/properties are not supported by this API; include required fields only
|
|
1227
|
+
url = f"{get_base_url()}/niworkorder/v1/workflows"
|
|
1228
|
+
wf_obj: Dict[str, Any] = {
|
|
1229
|
+
"name": name,
|
|
1230
|
+
"description": props.get("description", ""),
|
|
1231
|
+
"workspace": self.workspace_id or props.get("workspace", ""),
|
|
1232
|
+
"actions": [
|
|
1233
|
+
{
|
|
1234
|
+
"name": "START",
|
|
1235
|
+
"displayText": "Start",
|
|
1236
|
+
"privilegeSpecificity": ["ExecuteTest"],
|
|
1237
|
+
"executionAction": {"type": "MANUAL", "action": "START"},
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
"name": "COMPLETE",
|
|
1241
|
+
"displayText": "Complete",
|
|
1242
|
+
"privilegeSpecificity": ["Close"],
|
|
1243
|
+
"executionAction": {"type": "MANUAL", "action": "COMPLETE"},
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
"name": "RUN_NOTEBOOK",
|
|
1247
|
+
"displayText": "Run Notebook",
|
|
1248
|
+
"iconClass": None,
|
|
1249
|
+
"i18n": [],
|
|
1250
|
+
"privilegeSpecificity": ["ExecuteTest"],
|
|
1251
|
+
"executionAction": {
|
|
1252
|
+
"action": "RUN_NOTEBOOK",
|
|
1253
|
+
"type": "NOTEBOOK",
|
|
1254
|
+
"notebookId": "00000000-0000-0000-0000-000000000000",
|
|
1255
|
+
"parameters": {
|
|
1256
|
+
"partNumber": "<partNumber>",
|
|
1257
|
+
"dut": "<assignedTo>",
|
|
1258
|
+
"operator": "<assignedTo>",
|
|
1259
|
+
"testProgram": "<testProgram>",
|
|
1260
|
+
"location": "<properties.region>-<properties.facility>-<properties.lab>",
|
|
1261
|
+
},
|
|
1262
|
+
},
|
|
1263
|
+
},
|
|
1264
|
+
{
|
|
1265
|
+
"name": "PLAN_SCHEDULE",
|
|
1266
|
+
"displayText": "Schedule Test Plan",
|
|
1267
|
+
"iconClass": "SCHEDULE",
|
|
1268
|
+
"i18n": [],
|
|
1269
|
+
"privilegeSpecificity": [],
|
|
1270
|
+
"executionAction": {"action": "PLAN_SCHEDULE", "type": "SCHEDULE"},
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
"name": "RUN_JOB",
|
|
1274
|
+
"displayText": "Run Job",
|
|
1275
|
+
"iconClass": "DEPLOY",
|
|
1276
|
+
"i18n": [],
|
|
1277
|
+
"privilegeSpecificity": [],
|
|
1278
|
+
"executionAction": {
|
|
1279
|
+
"action": "RUN_JOB",
|
|
1280
|
+
"type": "JOB",
|
|
1281
|
+
"jobs": [
|
|
1282
|
+
{
|
|
1283
|
+
"functions": ["state.apply"],
|
|
1284
|
+
"arguments": [["<properties.startTestStateId>"]],
|
|
1285
|
+
"metadata": {},
|
|
1286
|
+
}
|
|
1287
|
+
],
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
],
|
|
1291
|
+
"states": [
|
|
1292
|
+
{
|
|
1293
|
+
"name": "NEW",
|
|
1294
|
+
"dashboardAvailable": False,
|
|
1295
|
+
"defaultSubstate": "NEW",
|
|
1296
|
+
"substates": [
|
|
1297
|
+
{
|
|
1298
|
+
"name": "NEW",
|
|
1299
|
+
"displayText": "New",
|
|
1300
|
+
"availableActions": [
|
|
1301
|
+
{
|
|
1302
|
+
"action": "PLAN_SCHEDULE",
|
|
1303
|
+
"nextState": "SCHEDULED",
|
|
1304
|
+
"nextSubstate": "SCHEDULED",
|
|
1305
|
+
"showInUI": True,
|
|
1306
|
+
}
|
|
1307
|
+
],
|
|
1308
|
+
}
|
|
1309
|
+
],
|
|
1310
|
+
},
|
|
1311
|
+
{
|
|
1312
|
+
"name": "DEFINED",
|
|
1313
|
+
"dashboardAvailable": False,
|
|
1314
|
+
"defaultSubstate": "DEFINED",
|
|
1315
|
+
"substates": [
|
|
1316
|
+
{
|
|
1317
|
+
"name": "DEFINED",
|
|
1318
|
+
"displayText": "Defined",
|
|
1319
|
+
"availableActions": [],
|
|
1320
|
+
}
|
|
1321
|
+
],
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
"name": "REVIEWED",
|
|
1325
|
+
"dashboardAvailable": False,
|
|
1326
|
+
"defaultSubstate": "REVIEWED",
|
|
1327
|
+
"substates": [
|
|
1328
|
+
{
|
|
1329
|
+
"name": "REVIEWED",
|
|
1330
|
+
"displayText": "Reviewed",
|
|
1331
|
+
"availableActions": [],
|
|
1332
|
+
}
|
|
1333
|
+
],
|
|
1334
|
+
},
|
|
1335
|
+
{
|
|
1336
|
+
"name": "SCHEDULED",
|
|
1337
|
+
"dashboardAvailable": True,
|
|
1338
|
+
"defaultSubstate": "SCHEDULED",
|
|
1339
|
+
"substates": [
|
|
1340
|
+
{
|
|
1341
|
+
"name": "SCHEDULED",
|
|
1342
|
+
"displayText": "Scheduled",
|
|
1343
|
+
"availableActions": [
|
|
1344
|
+
{
|
|
1345
|
+
"action": "START",
|
|
1346
|
+
"nextState": "IN_PROGRESS",
|
|
1347
|
+
"nextSubstate": "IN_PROGRESS",
|
|
1348
|
+
"showInUI": True,
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
"action": "RUN_NOTEBOOK",
|
|
1352
|
+
"nextState": "IN_PROGRESS",
|
|
1353
|
+
"nextSubstate": "IN_PROGRESS",
|
|
1354
|
+
"showInUI": True,
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
}
|
|
1358
|
+
],
|
|
1359
|
+
},
|
|
1360
|
+
{
|
|
1361
|
+
"name": "IN_PROGRESS",
|
|
1362
|
+
"dashboardAvailable": True,
|
|
1363
|
+
"defaultSubstate": "IN_PROGRESS",
|
|
1364
|
+
"substates": [
|
|
1365
|
+
{
|
|
1366
|
+
"name": "IN_PROGRESS",
|
|
1367
|
+
"displayText": "In progress",
|
|
1368
|
+
"availableActions": [
|
|
1369
|
+
{
|
|
1370
|
+
"action": "COMPLETE",
|
|
1371
|
+
"nextState": "PENDING_APPROVAL",
|
|
1372
|
+
"nextSubstate": "PENDING_APPROVAL",
|
|
1373
|
+
"showInUI": True,
|
|
1374
|
+
}
|
|
1375
|
+
],
|
|
1376
|
+
}
|
|
1377
|
+
],
|
|
1378
|
+
},
|
|
1379
|
+
{
|
|
1380
|
+
"name": "PENDING_APPROVAL",
|
|
1381
|
+
"dashboardAvailable": True,
|
|
1382
|
+
"defaultSubstate": "PENDING_APPROVAL",
|
|
1383
|
+
"substates": [
|
|
1384
|
+
{
|
|
1385
|
+
"name": "PENDING_APPROVAL",
|
|
1386
|
+
"displayText": "Pending approval",
|
|
1387
|
+
"availableActions": [
|
|
1388
|
+
{
|
|
1389
|
+
"action": "RUN_JOB",
|
|
1390
|
+
"nextState": "CLOSED",
|
|
1391
|
+
"nextSubstate": "CLOSED",
|
|
1392
|
+
"showInUI": True,
|
|
1393
|
+
}
|
|
1394
|
+
],
|
|
1395
|
+
}
|
|
1396
|
+
],
|
|
1397
|
+
},
|
|
1398
|
+
{
|
|
1399
|
+
"name": "CLOSED",
|
|
1400
|
+
"dashboardAvailable": False,
|
|
1401
|
+
"defaultSubstate": "CLOSED",
|
|
1402
|
+
"substates": [
|
|
1403
|
+
{"name": "CLOSED", "displayText": "Closed", "availableActions": []}
|
|
1404
|
+
],
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
"name": "CANCELED",
|
|
1408
|
+
"dashboardAvailable": False,
|
|
1409
|
+
"defaultSubstate": "CANCELED",
|
|
1410
|
+
"substates": [
|
|
1411
|
+
{"name": "CANCELED", "displayText": "Canceled", "availableActions": []}
|
|
1412
|
+
],
|
|
1413
|
+
},
|
|
1414
|
+
],
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
payload = wf_obj
|
|
1418
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1419
|
+
resp.raise_for_status()
|
|
1420
|
+
data = resp.json()
|
|
1421
|
+
# Create returns the created workflow object (id at root)
|
|
1422
|
+
if isinstance(data, dict) and data.get("id"):
|
|
1423
|
+
return str(data.get("id"))
|
|
1424
|
+
return None
|
|
1425
|
+
except Exception:
|
|
1426
|
+
return None
|
|
1427
|
+
|
|
1428
|
+
def _get_workflow_by_name(self, name: str) -> Optional[str]:
|
|
1429
|
+
"""Look up workflow by name via /niworkorder/v1/query-workflows.
|
|
1430
|
+
|
|
1431
|
+
Returns workflow ID if found, None otherwise.
|
|
1432
|
+
"""
|
|
1433
|
+
if not name:
|
|
1434
|
+
return None
|
|
1435
|
+
|
|
1436
|
+
try:
|
|
1437
|
+
url = f"{get_base_url()}/niworkorder/v1/query-workflows"
|
|
1438
|
+
payload = {
|
|
1439
|
+
"filter": "name == @0",
|
|
1440
|
+
"substitutions": [name],
|
|
1441
|
+
"projection": ["ID", "NAME"],
|
|
1442
|
+
"take": 100, # Get more results to verify exact match
|
|
1443
|
+
}
|
|
1444
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1445
|
+
resp.raise_for_status()
|
|
1446
|
+
data = resp.json()
|
|
1447
|
+
if "workflows" in data:
|
|
1448
|
+
# Find exact case-insensitive match
|
|
1449
|
+
for workflow in data["workflows"]:
|
|
1450
|
+
if workflow.get("name", "").lower() == name.lower():
|
|
1451
|
+
return workflow.get("id")
|
|
1452
|
+
return None
|
|
1453
|
+
except Exception:
|
|
1454
|
+
return None
|
|
1455
|
+
|
|
1456
|
+
def _get_workflow_ids_by_name(self, name: str) -> List[str]:
|
|
1457
|
+
"""Return all workflow IDs with exact name; include workspace if supported."""
|
|
1458
|
+
ids: List[str] = []
|
|
1459
|
+
if not name:
|
|
1460
|
+
return ids
|
|
1461
|
+
try:
|
|
1462
|
+
url = f"{get_base_url()}/niworkorder/v1/query-workflows"
|
|
1463
|
+
filter_str = "name == @0"
|
|
1464
|
+
subs: List[str] = [name]
|
|
1465
|
+
if self.workspace_id:
|
|
1466
|
+
filter_str += " and workspace == @1"
|
|
1467
|
+
subs.append(self.workspace_id)
|
|
1468
|
+
payload = {
|
|
1469
|
+
"filter": filter_str,
|
|
1470
|
+
"substitutions": subs,
|
|
1471
|
+
"projection": ["ID", "NAME"],
|
|
1472
|
+
"take": 500,
|
|
1473
|
+
}
|
|
1474
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1475
|
+
resp.raise_for_status()
|
|
1476
|
+
data = resp.json()
|
|
1477
|
+
for wf in data.get("workflows", []) or []:
|
|
1478
|
+
if str(wf.get("name", "")).lower() == name.lower():
|
|
1479
|
+
wid = wf.get("id")
|
|
1480
|
+
if wid:
|
|
1481
|
+
ids.append(wid)
|
|
1482
|
+
except Exception:
|
|
1483
|
+
return ids
|
|
1484
|
+
return ids
|
|
1485
|
+
|
|
1486
|
+
def _delete_workflow(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1487
|
+
"""Delete workflow via /niworkorder/v1/delete-workflows.
|
|
1488
|
+
|
|
1489
|
+
Returns ID if deleted, None otherwise.
|
|
1490
|
+
"""
|
|
1491
|
+
name = props.get("name", "")
|
|
1492
|
+
if not name:
|
|
1493
|
+
return None
|
|
1494
|
+
|
|
1495
|
+
workflow_ids = self._get_workflow_ids_by_name(name)
|
|
1496
|
+
if not workflow_ids:
|
|
1497
|
+
return None
|
|
1498
|
+
|
|
1499
|
+
try:
|
|
1500
|
+
url = f"{get_base_url()}/niworkorder/v1/delete-workflows"
|
|
1501
|
+
payload = {"ids": workflow_ids}
|
|
1502
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1503
|
+
resp.raise_for_status()
|
|
1504
|
+
return workflow_ids[0]
|
|
1505
|
+
except Exception:
|
|
1506
|
+
return None
|
|
1507
|
+
|
|
1508
|
+
# ========================================================================
|
|
1509
|
+
# Work Item Methods (Tier 2)
|
|
1510
|
+
# ========================================================================
|
|
1511
|
+
|
|
1512
|
+
def _create_work_item(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1513
|
+
"""Create work item via /niworkitem/v1/workitems.
|
|
1514
|
+
|
|
1515
|
+
Returns work item ID if created, None on error.
|
|
1516
|
+
"""
|
|
1517
|
+
name = props.get("name", "")
|
|
1518
|
+
if not name:
|
|
1519
|
+
return None
|
|
1520
|
+
|
|
1521
|
+
try:
|
|
1522
|
+
url = f"{get_base_url()}/niworkitem/v1/workitems"
|
|
1523
|
+
wi_obj: Dict[str, Any] = {
|
|
1524
|
+
"name": name,
|
|
1525
|
+
"description": props.get("description", ""),
|
|
1526
|
+
"state": props.get("state", "NEW"),
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
# Add mandatory partNumber for testplan work items (derived from name if not provided)
|
|
1530
|
+
work_item_type = props.get("work_item_type", "testplan")
|
|
1531
|
+
if work_item_type == "testplan":
|
|
1532
|
+
# PartNumber is mandatory for testplan type
|
|
1533
|
+
part_number = props.get("partNumber")
|
|
1534
|
+
if not part_number:
|
|
1535
|
+
# Generate from name: replace spaces with hyphens, use first 50 chars
|
|
1536
|
+
part_number = name.replace(" ", "-")[:50]
|
|
1537
|
+
wi_obj["partNumber"] = part_number
|
|
1538
|
+
|
|
1539
|
+
# Only include workspace if we have a specific workspace ID
|
|
1540
|
+
if self.workspace_id and self.workspace_id.strip():
|
|
1541
|
+
wi_obj["workspace"] = self.workspace_id
|
|
1542
|
+
# Map template and type if provided
|
|
1543
|
+
if "test_template_id" in props:
|
|
1544
|
+
template_id = props["test_template_id"]
|
|
1545
|
+
# Resolve template reference from id_map (e.g., "${tt_acs_validation}" -> "508660")
|
|
1546
|
+
if isinstance(template_id, str):
|
|
1547
|
+
# Remove ${} wrapper if present
|
|
1548
|
+
if template_id.startswith("${") and template_id.endswith("}"):
|
|
1549
|
+
template_ref = template_id[2:-1] # Extract reference name
|
|
1550
|
+
# Look up actual ID from id_map
|
|
1551
|
+
if template_ref in self.id_map:
|
|
1552
|
+
template_id = self.id_map[template_ref]
|
|
1553
|
+
else:
|
|
1554
|
+
# Template reference not found in id_map
|
|
1555
|
+
raise Exception(
|
|
1556
|
+
f"Template '{template_ref}' not found in id_map - template may not have been created successfully"
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
if not template_id or (isinstance(template_id, str) and not template_id.strip()):
|
|
1560
|
+
raise Exception("Template ID is empty - template creation may have failed")
|
|
1561
|
+
wi_obj["templateId"] = template_id
|
|
1562
|
+
if "work_item_type" in props:
|
|
1563
|
+
wi_obj["type"] = props["work_item_type"]
|
|
1564
|
+
# Reserve DUT/system resources if provided
|
|
1565
|
+
resources: Dict[str, Any] = {}
|
|
1566
|
+
if "scheduled_dut" in props:
|
|
1567
|
+
dut_id = props["scheduled_dut"]
|
|
1568
|
+
# Resolve reference from id_map
|
|
1569
|
+
if isinstance(dut_id, str):
|
|
1570
|
+
dut_ref = dut_id
|
|
1571
|
+
if dut_ref.startswith("${") and dut_ref.endswith("}"):
|
|
1572
|
+
dut_ref = dut_ref[2:-1]
|
|
1573
|
+
if dut_ref in self.id_map:
|
|
1574
|
+
dut_id = self.id_map[dut_ref]
|
|
1575
|
+
elif not dut_ref.startswith("${"):
|
|
1576
|
+
# dut_ref is not a reference wrapper, use as-is
|
|
1577
|
+
dut_id = dut_ref
|
|
1578
|
+
else:
|
|
1579
|
+
# Reference not found
|
|
1580
|
+
raise Exception(
|
|
1581
|
+
f"DUT '{dut_ref}' not found in id_map - DUT may not have been created successfully"
|
|
1582
|
+
)
|
|
1583
|
+
if dut_id:
|
|
1584
|
+
resources["duts"] = {"selections": [{"id": dut_id}]}
|
|
1585
|
+
if "scheduled_system" in props:
|
|
1586
|
+
sys_id = props["scheduled_system"]
|
|
1587
|
+
# Resolve reference from id_map
|
|
1588
|
+
if isinstance(sys_id, str):
|
|
1589
|
+
sys_ref = sys_id
|
|
1590
|
+
if sys_ref.startswith("${") and sys_ref.endswith("}"):
|
|
1591
|
+
sys_ref = sys_ref[2:-1]
|
|
1592
|
+
if sys_ref in self.id_map:
|
|
1593
|
+
sys_id = self.id_map[sys_ref]
|
|
1594
|
+
elif not sys_ref.startswith("${"):
|
|
1595
|
+
# sys_ref is not a reference wrapper, use as-is
|
|
1596
|
+
sys_id = sys_ref
|
|
1597
|
+
else:
|
|
1598
|
+
# Reference not found
|
|
1599
|
+
raise Exception(
|
|
1600
|
+
f"System '{sys_ref}' not found in id_map - System may not have been created successfully"
|
|
1601
|
+
)
|
|
1602
|
+
if sys_id:
|
|
1603
|
+
resources["systems"] = {"selections": [{"id": sys_id}]}
|
|
1604
|
+
if resources:
|
|
1605
|
+
wi_obj["resources"] = resources
|
|
1606
|
+
# Merge properties
|
|
1607
|
+
if "properties" in props and isinstance(props["properties"], dict):
|
|
1608
|
+
wi_obj["properties"] = props["properties"]
|
|
1609
|
+
# Add keywords for precise cleanup
|
|
1610
|
+
kw: List[str] = []
|
|
1611
|
+
if isinstance(props.get("keywords"), list):
|
|
1612
|
+
kw.extend([str(x) for x in props.get("keywords", [])])
|
|
1613
|
+
if isinstance(props.get("tags"), list):
|
|
1614
|
+
kw.extend([str(x) for x in props.get("tags", [])])
|
|
1615
|
+
if self.example_name:
|
|
1616
|
+
kw.append(f"slcli-example:{self.example_name}")
|
|
1617
|
+
if kw:
|
|
1618
|
+
wi_obj["keywords"] = self._deduplicate_keywords(kw)
|
|
1619
|
+
payload = {"workItems": [wi_obj]}
|
|
1620
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1621
|
+
resp.raise_for_status()
|
|
1622
|
+
data = resp.json()
|
|
1623
|
+
|
|
1624
|
+
# Handle poorly-designed API: 200 response with failures
|
|
1625
|
+
# Check for error object or empty created list
|
|
1626
|
+
has_error = data.get("error") is not None
|
|
1627
|
+
created = data.get("createdWorkItems") or []
|
|
1628
|
+
|
|
1629
|
+
if has_error and not created:
|
|
1630
|
+
error_msg = data["error"].get("message", "Unknown error")
|
|
1631
|
+
if data["error"].get("innerErrors"):
|
|
1632
|
+
inner = data["error"]["innerErrors"][0]
|
|
1633
|
+
error_msg = inner.get("message", error_msg)
|
|
1634
|
+
raise Exception(f"Work item creation failed: {error_msg}")
|
|
1635
|
+
|
|
1636
|
+
# If we have created work items, return the first one's ID
|
|
1637
|
+
if created:
|
|
1638
|
+
created_id = created[0].get("id")
|
|
1639
|
+
if created_id:
|
|
1640
|
+
return str(created_id)
|
|
1641
|
+
|
|
1642
|
+
# Fallback: check alternate response format
|
|
1643
|
+
if "workItems" in data and len(data["workItems"]) > 0:
|
|
1644
|
+
work_item_id = data["workItems"][0].get("id")
|
|
1645
|
+
if work_item_id:
|
|
1646
|
+
return str(work_item_id)
|
|
1647
|
+
|
|
1648
|
+
# Fallback: lookup by name if ID not returned
|
|
1649
|
+
looked_up_id = self._get_work_item_by_name(name)
|
|
1650
|
+
if looked_up_id:
|
|
1651
|
+
return looked_up_id
|
|
1652
|
+
|
|
1653
|
+
# If still no ID, raise exception to ensure we know creation failed
|
|
1654
|
+
raise Exception(f"Work item creation returned no ID: {data}")
|
|
1655
|
+
except requests.exceptions.HTTPError:
|
|
1656
|
+
# Let HTTP errors propagate to the caller's error handler
|
|
1657
|
+
raise
|
|
1658
|
+
except Exception as exc:
|
|
1659
|
+
# Wrap other exceptions with context
|
|
1660
|
+
raise Exception(f"Failed to create work item '{name}': {exc}") from exc
|
|
1661
|
+
|
|
1662
|
+
def _get_work_item_by_name(self, name: str) -> Optional[str]:
|
|
1663
|
+
"""Look up work item by name via /niworkitem/v1/query-workitems.
|
|
1664
|
+
|
|
1665
|
+
Returns work item ID if found, None otherwise.
|
|
1666
|
+
"""
|
|
1667
|
+
if not name:
|
|
1668
|
+
return None
|
|
1669
|
+
|
|
1670
|
+
try:
|
|
1671
|
+
url = f"{get_base_url()}/niworkitem/v1/query-workitems"
|
|
1672
|
+
filter_str = f"name == @0"
|
|
1673
|
+
if self.workspace_id:
|
|
1674
|
+
filter_str += f" and workspace == @1"
|
|
1675
|
+
payload = {
|
|
1676
|
+
"filter": filter_str,
|
|
1677
|
+
"substitutions": ([name, self.workspace_id] if self.workspace_id else [name]),
|
|
1678
|
+
"projection": ["ID", "NAME"],
|
|
1679
|
+
"take": 100,
|
|
1680
|
+
}
|
|
1681
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1682
|
+
resp.raise_for_status()
|
|
1683
|
+
data = resp.json()
|
|
1684
|
+
if "workItems" in data and len(data["workItems"]) > 0:
|
|
1685
|
+
# Find exact case-insensitive match
|
|
1686
|
+
for item in data["workItems"]:
|
|
1687
|
+
if item.get("name", "").lower() == name.lower():
|
|
1688
|
+
return item.get("id")
|
|
1689
|
+
return None
|
|
1690
|
+
except Exception:
|
|
1691
|
+
return None
|
|
1692
|
+
|
|
1693
|
+
def _get_work_item_ids_by_name(self, name: str) -> List[str]:
|
|
1694
|
+
"""Return all work item IDs with exact name in current workspace."""
|
|
1695
|
+
ids: List[str] = []
|
|
1696
|
+
if not name:
|
|
1697
|
+
return ids
|
|
1698
|
+
try:
|
|
1699
|
+
url = f"{get_base_url()}/niworkitem/v1/query-workitems"
|
|
1700
|
+
filter_str = f"name == @0"
|
|
1701
|
+
subs: List[str] = [name]
|
|
1702
|
+
# Only filter by workspace if we have a specific workspace ID
|
|
1703
|
+
# Note: workspace_id can be None or empty string - both mean default workspace
|
|
1704
|
+
if self.workspace_id and self.workspace_id.strip():
|
|
1705
|
+
filter_str += f" and workspace == @1"
|
|
1706
|
+
subs.append(self.workspace_id)
|
|
1707
|
+
payload = {
|
|
1708
|
+
"filter": filter_str,
|
|
1709
|
+
"substitutions": subs,
|
|
1710
|
+
"projection": ["ID", "NAME"],
|
|
1711
|
+
"take": 500,
|
|
1712
|
+
}
|
|
1713
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1714
|
+
resp.raise_for_status()
|
|
1715
|
+
data = resp.json()
|
|
1716
|
+
for item in data.get("workItems", []) or []:
|
|
1717
|
+
if str(item.get("name", "")).lower() == name.lower():
|
|
1718
|
+
iid = item.get("id")
|
|
1719
|
+
if iid:
|
|
1720
|
+
ids.append(iid)
|
|
1721
|
+
except Exception:
|
|
1722
|
+
return ids
|
|
1723
|
+
return ids
|
|
1724
|
+
|
|
1725
|
+
def _delete_work_item(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1726
|
+
"""Delete work item via /niworkitem/v1/delete-workitems.
|
|
1727
|
+
|
|
1728
|
+
Returns ID if deleted, None otherwise.
|
|
1729
|
+
"""
|
|
1730
|
+
name = props.get("name", "")
|
|
1731
|
+
if not name:
|
|
1732
|
+
return None
|
|
1733
|
+
|
|
1734
|
+
work_item_ids = self._get_work_item_ids_by_name(name)
|
|
1735
|
+
if not work_item_ids:
|
|
1736
|
+
return None
|
|
1737
|
+
|
|
1738
|
+
try:
|
|
1739
|
+
url = f"{get_base_url()}/niworkitem/v1/delete-workitems"
|
|
1740
|
+
payload = {"ids": work_item_ids}
|
|
1741
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1742
|
+
resp.raise_for_status()
|
|
1743
|
+
return work_item_ids[0]
|
|
1744
|
+
except Exception:
|
|
1745
|
+
return None
|
|
1746
|
+
|
|
1747
|
+
# ========================================================================
|
|
1748
|
+
# Work Order Methods (Tier 2)
|
|
1749
|
+
# ========================================================================
|
|
1750
|
+
|
|
1751
|
+
def _create_work_order(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1752
|
+
"""Create work order via /niworkorder/v1/workorders.
|
|
1753
|
+
|
|
1754
|
+
Returns work order ID if created, None on error.
|
|
1755
|
+
"""
|
|
1756
|
+
name = props.get("name", "")
|
|
1757
|
+
if not name:
|
|
1758
|
+
return None
|
|
1759
|
+
|
|
1760
|
+
try:
|
|
1761
|
+
url = f"{get_base_url()}/niworkorder/v1/workorders"
|
|
1762
|
+
|
|
1763
|
+
# Work order state is mandatory - use explicit state if provided,
|
|
1764
|
+
# otherwise default to NEW
|
|
1765
|
+
raw_state = props.get("state") or "NEW"
|
|
1766
|
+
state = str(raw_state).upper()
|
|
1767
|
+
|
|
1768
|
+
# Map optional fields to API schema
|
|
1769
|
+
# Normalize work order type; default to TEST_REQUEST and override only when valid
|
|
1770
|
+
provided_type = props.get("work_order_type")
|
|
1771
|
+
work_order_type = "TEST_REQUEST"
|
|
1772
|
+
if provided_type:
|
|
1773
|
+
candidate = str(provided_type).upper()
|
|
1774
|
+
if candidate == "TEST_REQUEST":
|
|
1775
|
+
work_order_type = candidate
|
|
1776
|
+
requested_by = props.get("requested_by")
|
|
1777
|
+
assigned_to = props.get("assigned_to") or props.get("assigned_team")
|
|
1778
|
+
earliest_start = props.get("scheduled_start") or props.get("earliest_start")
|
|
1779
|
+
due_date = props.get("scheduled_end") or props.get("due_date")
|
|
1780
|
+
|
|
1781
|
+
wo_body: Dict[str, Any] = {
|
|
1782
|
+
"name": name,
|
|
1783
|
+
"description": props.get("description", ""),
|
|
1784
|
+
"state": state,
|
|
1785
|
+
"type": work_order_type,
|
|
1786
|
+
"workspace": self.workspace_id or props.get("workspace"),
|
|
1787
|
+
"properties": props.get("properties", {}),
|
|
1788
|
+
# Request field is required; include minimal object if not provided
|
|
1789
|
+
"request": props.get("request") or {"properties": {}},
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
# Only include optional fields when present
|
|
1793
|
+
if requested_by:
|
|
1794
|
+
wo_body["requestedBy"] = requested_by
|
|
1795
|
+
if assigned_to:
|
|
1796
|
+
wo_body["assignedTo"] = assigned_to
|
|
1797
|
+
if earliest_start:
|
|
1798
|
+
wo_body["earliestStartDate"] = earliest_start
|
|
1799
|
+
if due_date:
|
|
1800
|
+
wo_body["dueDate"] = due_date
|
|
1801
|
+
|
|
1802
|
+
# API expects capitalized collection name
|
|
1803
|
+
payload = {"workOrders": [wo_body]}
|
|
1804
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1805
|
+
resp.raise_for_status()
|
|
1806
|
+
data = resp.json()
|
|
1807
|
+
|
|
1808
|
+
# Handle poorly-designed API: 200 response with failures
|
|
1809
|
+
has_error = data.get("error") is not None
|
|
1810
|
+
created = data.get("createdWorkOrders") or []
|
|
1811
|
+
|
|
1812
|
+
if has_error and not created:
|
|
1813
|
+
error_msg = data["error"].get("message", "Unknown error")
|
|
1814
|
+
if data["error"].get("innerErrors"):
|
|
1815
|
+
inner = data["error"]["innerErrors"][0]
|
|
1816
|
+
error_msg = inner.get("message", error_msg)
|
|
1817
|
+
raise Exception(f"Work order creation failed: {error_msg}")
|
|
1818
|
+
|
|
1819
|
+
if "workOrders" in data and len(data["workOrders"]) > 0:
|
|
1820
|
+
return data["workOrders"][0].get("id") or str(hash(name))
|
|
1821
|
+
|
|
1822
|
+
# Handle standard responses
|
|
1823
|
+
if created:
|
|
1824
|
+
created_id = created[0].get("id")
|
|
1825
|
+
if created_id:
|
|
1826
|
+
return str(created_id)
|
|
1827
|
+
|
|
1828
|
+
# Fallback: lookup by name if ID not returned
|
|
1829
|
+
looked_up_id = self._get_work_order_by_name(name)
|
|
1830
|
+
if looked_up_id:
|
|
1831
|
+
return looked_up_id
|
|
1832
|
+
|
|
1833
|
+
# If still no ID, raise exception to ensure we know creation failed
|
|
1834
|
+
raise Exception(f"Work order creation returned no ID: {data}")
|
|
1835
|
+
except requests.exceptions.HTTPError as http_err:
|
|
1836
|
+
# Extract error details from HTTP response
|
|
1837
|
+
try:
|
|
1838
|
+
error_body = http_err.response.json() # type: ignore
|
|
1839
|
+
error_msg = error_body.get("error", {}).get("message", str(http_err))
|
|
1840
|
+
if error_body.get("error", {}).get("innerErrors"):
|
|
1841
|
+
inner = error_body["error"]["innerErrors"][0]
|
|
1842
|
+
error_msg = inner.get("message", error_msg)
|
|
1843
|
+
raise Exception(f"Work order creation failed: {error_msg}")
|
|
1844
|
+
except Exception:
|
|
1845
|
+
raise Exception(f"Work order creation failed: {http_err}")
|
|
1846
|
+
except Exception as exc:
|
|
1847
|
+
# Wrap other exceptions with context
|
|
1848
|
+
raise Exception(f"Failed to create work order '{name}': {exc}") from exc
|
|
1849
|
+
|
|
1850
|
+
def _get_work_order_by_name(self, name: str) -> Optional[str]:
|
|
1851
|
+
"""Look up work order by name via /niworkorder/v1/query-workorders.
|
|
1852
|
+
|
|
1853
|
+
Returns work order ID if found, None otherwise.
|
|
1854
|
+
"""
|
|
1855
|
+
if not name:
|
|
1856
|
+
return None
|
|
1857
|
+
|
|
1858
|
+
try:
|
|
1859
|
+
url = f"{get_base_url()}/niworkorder/v1/query-workorders"
|
|
1860
|
+
filter_str = f"name == @0"
|
|
1861
|
+
if self.workspace_id:
|
|
1862
|
+
filter_str += f" and workspace == @1"
|
|
1863
|
+
payload = {
|
|
1864
|
+
"filter": filter_str,
|
|
1865
|
+
"substitutions": ([name, self.workspace_id] if self.workspace_id else [name]),
|
|
1866
|
+
"projection": ["ID", "NAME"],
|
|
1867
|
+
"take": 100,
|
|
1868
|
+
}
|
|
1869
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1870
|
+
resp.raise_for_status()
|
|
1871
|
+
data = resp.json()
|
|
1872
|
+
if "workOrders" in data and len(data["workOrders"]) > 0:
|
|
1873
|
+
# Find exact case-insensitive match
|
|
1874
|
+
for order in data["workOrders"]:
|
|
1875
|
+
if order.get("name", "").lower() == name.lower():
|
|
1876
|
+
return order.get("id")
|
|
1877
|
+
return None
|
|
1878
|
+
except Exception:
|
|
1879
|
+
return None
|
|
1880
|
+
|
|
1881
|
+
def _get_work_order_ids_by_name(self, name: str) -> List[str]:
|
|
1882
|
+
"""Return all work order IDs with exact name in current workspace."""
|
|
1883
|
+
ids: List[str] = []
|
|
1884
|
+
if not name:
|
|
1885
|
+
return ids
|
|
1886
|
+
try:
|
|
1887
|
+
url = f"{get_base_url()}/niworkorder/v1/query-workorders"
|
|
1888
|
+
filter_str = f"name == @0"
|
|
1889
|
+
subs: List[str] = [name]
|
|
1890
|
+
if self.workspace_id:
|
|
1891
|
+
filter_str += f" and workspace == @1"
|
|
1892
|
+
subs.append(self.workspace_id)
|
|
1893
|
+
payload = {
|
|
1894
|
+
"filter": filter_str,
|
|
1895
|
+
"substitutions": subs,
|
|
1896
|
+
"projection": ["ID", "NAME"],
|
|
1897
|
+
"take": 500,
|
|
1898
|
+
}
|
|
1899
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1900
|
+
resp.raise_for_status()
|
|
1901
|
+
data = resp.json()
|
|
1902
|
+
for wo in data.get("workOrders", []) or []:
|
|
1903
|
+
if str(wo.get("name", "")).lower() == name.lower():
|
|
1904
|
+
wid = wo.get("id")
|
|
1905
|
+
if wid:
|
|
1906
|
+
ids.append(wid)
|
|
1907
|
+
except Exception:
|
|
1908
|
+
return ids
|
|
1909
|
+
return ids
|
|
1910
|
+
|
|
1911
|
+
def _delete_work_order(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1912
|
+
"""Delete work order via /niworkorder/v1/delete-workorders.
|
|
1913
|
+
|
|
1914
|
+
Returns ID if deleted, None otherwise.
|
|
1915
|
+
"""
|
|
1916
|
+
name = props.get("name", "")
|
|
1917
|
+
if not name:
|
|
1918
|
+
return None
|
|
1919
|
+
|
|
1920
|
+
work_order_ids = self._get_work_order_ids_by_name(name)
|
|
1921
|
+
if not work_order_ids:
|
|
1922
|
+
return None
|
|
1923
|
+
|
|
1924
|
+
try:
|
|
1925
|
+
url = f"{get_base_url()}/niworkorder/v1/delete-workorders"
|
|
1926
|
+
payload = {"ids": work_order_ids}
|
|
1927
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1928
|
+
resp.raise_for_status()
|
|
1929
|
+
return work_order_ids[0]
|
|
1930
|
+
except Exception:
|
|
1931
|
+
return None
|
|
1932
|
+
|
|
1933
|
+
# ========================================================================
|
|
1934
|
+
# Test Result Methods (Tier 3)
|
|
1935
|
+
# ========================================================================
|
|
1936
|
+
|
|
1937
|
+
def _create_test_result(self, props: Dict[str, Any]) -> Optional[str]:
|
|
1938
|
+
"""Create test result via /nitestmonitor/v2/results.
|
|
1939
|
+
|
|
1940
|
+
Returns test result ID if created, None on error.
|
|
1941
|
+
"""
|
|
1942
|
+
program_name = props.get("program_name") or props.get("test_phase") or props.get("name")
|
|
1943
|
+
if not program_name:
|
|
1944
|
+
return None
|
|
1945
|
+
|
|
1946
|
+
status_str = str(props.get("status", "passed")).upper()
|
|
1947
|
+
status_map = {
|
|
1948
|
+
"PASSED": "PASSED",
|
|
1949
|
+
"FAILED": "FAILED",
|
|
1950
|
+
"DONE": "DONE",
|
|
1951
|
+
"RUNNING": "RUNNING",
|
|
1952
|
+
"SKIPPED": "SKIPPED",
|
|
1953
|
+
}
|
|
1954
|
+
status_type = status_map.get(status_str, "PASSED")
|
|
1955
|
+
|
|
1956
|
+
try:
|
|
1957
|
+
url = f"{get_base_url()}/nitestmonitor/v2/results"
|
|
1958
|
+
result_obj: Dict[str, Any] = {
|
|
1959
|
+
"programName": program_name,
|
|
1960
|
+
"status": {"statusType": status_type, "statusName": status_type.capitalize()},
|
|
1961
|
+
"workspace": self.workspace_id or "",
|
|
1962
|
+
}
|
|
1963
|
+
if "operator" in props:
|
|
1964
|
+
result_obj["operator"] = props["operator"]
|
|
1965
|
+
if "system_id" in props:
|
|
1966
|
+
result_obj["systemId"] = props["system_id"]
|
|
1967
|
+
if "serial_number" in props:
|
|
1968
|
+
result_obj["serialNumber"] = props["serial_number"]
|
|
1969
|
+
if "part_number" in props:
|
|
1970
|
+
result_obj["partNumber"] = props["part_number"]
|
|
1971
|
+
if "start_time" in props:
|
|
1972
|
+
result_obj["startedAt"] = props["start_time"]
|
|
1973
|
+
# Merge measurement key-values into properties
|
|
1974
|
+
measurements = props.get("measurements", {})
|
|
1975
|
+
if isinstance(measurements, dict) and measurements:
|
|
1976
|
+
props_map = {str(k): str(v) for k, v in measurements.items()}
|
|
1977
|
+
# include existing properties if provided
|
|
1978
|
+
if "properties" in props and isinstance(props["properties"], dict):
|
|
1979
|
+
props_map.update({str(k): str(v) for k, v in props["properties"].items()})
|
|
1980
|
+
result_obj["properties"] = props_map
|
|
1981
|
+
|
|
1982
|
+
# Add keywords for precise cleanup
|
|
1983
|
+
kw: List[str] = []
|
|
1984
|
+
if isinstance(props.get("keywords"), list):
|
|
1985
|
+
kw.extend([str(x) for x in props.get("keywords", [])])
|
|
1986
|
+
if isinstance(props.get("tags"), list):
|
|
1987
|
+
kw.extend([str(x) for x in props.get("tags", [])])
|
|
1988
|
+
# Always tag results for cleanup, even without an example name
|
|
1989
|
+
kw.append("slcli-provisioner")
|
|
1990
|
+
if self.example_name:
|
|
1991
|
+
kw.append(f"slcli-example:{self.example_name}")
|
|
1992
|
+
if kw:
|
|
1993
|
+
result_obj["keywords"] = self._deduplicate_keywords(kw)
|
|
1994
|
+
|
|
1995
|
+
payload = {"results": [result_obj]}
|
|
1996
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
1997
|
+
data = resp.json()
|
|
1998
|
+
# Supports 200 (partial) and 201 (success) with the same shape
|
|
1999
|
+
results = data.get("results", [])
|
|
2000
|
+
if results:
|
|
2001
|
+
rid = results[0].get("id")
|
|
2002
|
+
if rid:
|
|
2003
|
+
# Create steps if specified in props
|
|
2004
|
+
steps_cfg = props.get("steps")
|
|
2005
|
+
if isinstance(steps_cfg, list) and steps_cfg:
|
|
2006
|
+
self._create_test_steps(str(rid), steps_cfg, result_obj.get("keywords", []))
|
|
2007
|
+
return str(rid)
|
|
2008
|
+
return None
|
|
2009
|
+
except Exception:
|
|
2010
|
+
return None
|
|
2011
|
+
|
|
2012
|
+
def _create_test_steps(
|
|
2013
|
+
self,
|
|
2014
|
+
result_id: str,
|
|
2015
|
+
steps: List[Dict[str, Any]],
|
|
2016
|
+
result_keywords: List[str],
|
|
2017
|
+
) -> None:
|
|
2018
|
+
"""Create test steps for an existing result via POST /nitestmonitor/v2/steps.
|
|
2019
|
+
|
|
2020
|
+
Each entry in `steps` may contain:
|
|
2021
|
+
- name (str)
|
|
2022
|
+
- step_type / stepType (str, default "NumericLimitTest")
|
|
2023
|
+
- status (str: passed/failed/done/running/skipped)
|
|
2024
|
+
- step_id / stepId (str, optional — server auto-generates if absent)
|
|
2025
|
+
- parent_id / parentId (str, optional)
|
|
2026
|
+
- started_at / startedAt (ISO-8601 str, optional)
|
|
2027
|
+
- total_time_in_seconds / totalTimeInSeconds (float, optional)
|
|
2028
|
+
- data (dict with optional keys: text, parameters)
|
|
2029
|
+
- parameters is a list of dicts (each dict is a string key-value map)
|
|
2030
|
+
- inputs (list of {name, value} dicts, optional)
|
|
2031
|
+
- outputs (list of {name, value} dicts, optional)
|
|
2032
|
+
- properties (dict of string key-value pairs, optional)
|
|
2033
|
+
- children (list of nested step dicts, optional)
|
|
2034
|
+
"""
|
|
2035
|
+
status_map = {
|
|
2036
|
+
"PASSED": "PASSED",
|
|
2037
|
+
"FAILED": "FAILED",
|
|
2038
|
+
"DONE": "DONE",
|
|
2039
|
+
"RUNNING": "RUNNING",
|
|
2040
|
+
"SKIPPED": "SKIPPED",
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
def _build_step(step_cfg: Dict[str, Any]) -> Dict[str, Any]:
|
|
2044
|
+
"""Recursively build a TestStepRequestObject from config dict."""
|
|
2045
|
+
step_obj: Dict[str, Any] = {
|
|
2046
|
+
"resultId": result_id,
|
|
2047
|
+
}
|
|
2048
|
+
name = step_cfg.get("name")
|
|
2049
|
+
if name:
|
|
2050
|
+
step_obj["name"] = str(name)
|
|
2051
|
+
|
|
2052
|
+
# step_type accepts both snake_case and camelCase
|
|
2053
|
+
step_type = step_cfg.get("step_type") or step_cfg.get("stepType") or "NumericLimitTest"
|
|
2054
|
+
step_obj["stepType"] = str(step_type)
|
|
2055
|
+
|
|
2056
|
+
# dataModel (optional, defaults to TestStand for NumericLimitTest)
|
|
2057
|
+
data_model = step_cfg.get("data_model") or step_cfg.get("dataModel")
|
|
2058
|
+
if data_model:
|
|
2059
|
+
step_obj["dataModel"] = str(data_model)
|
|
2060
|
+
|
|
2061
|
+
# status
|
|
2062
|
+
raw_status = str(step_cfg.get("status", "passed")).upper()
|
|
2063
|
+
if raw_status not in status_map:
|
|
2064
|
+
click.echo(
|
|
2065
|
+
f"Warning: unrecognized step status '{step_cfg.get('status')}' "
|
|
2066
|
+
f"for step '{step_cfg.get('name')}', defaulting to PASSED",
|
|
2067
|
+
err=True,
|
|
2068
|
+
)
|
|
2069
|
+
status_type = status_map.get(raw_status, "PASSED")
|
|
2070
|
+
step_obj["status"] = {"statusType": status_type, "statusName": status_type.capitalize()}
|
|
2071
|
+
|
|
2072
|
+
# optional ID fields
|
|
2073
|
+
step_id = step_cfg.get("step_id") or step_cfg.get("stepId")
|
|
2074
|
+
if step_id:
|
|
2075
|
+
step_obj["stepId"] = str(step_id)
|
|
2076
|
+
parent_id = step_cfg.get("parent_id") or step_cfg.get("parentId")
|
|
2077
|
+
if parent_id:
|
|
2078
|
+
step_obj["parentId"] = str(parent_id)
|
|
2079
|
+
|
|
2080
|
+
# timestamps / timing
|
|
2081
|
+
started_at = step_cfg.get("started_at") or step_cfg.get("startedAt")
|
|
2082
|
+
if started_at:
|
|
2083
|
+
step_obj["startedAt"] = str(started_at)
|
|
2084
|
+
tts = step_cfg.get("total_time_in_seconds") or step_cfg.get("totalTimeInSeconds")
|
|
2085
|
+
if tts is not None:
|
|
2086
|
+
step_obj["totalTimeInSeconds"] = float(tts)
|
|
2087
|
+
|
|
2088
|
+
# data block (text + parameters)
|
|
2089
|
+
data_cfg = step_cfg.get("data")
|
|
2090
|
+
if isinstance(data_cfg, dict):
|
|
2091
|
+
data_obj: Dict[str, Any] = {}
|
|
2092
|
+
if "text" in data_cfg:
|
|
2093
|
+
data_obj["text"] = str(data_cfg["text"])
|
|
2094
|
+
params = data_cfg.get("parameters")
|
|
2095
|
+
if isinstance(params, list):
|
|
2096
|
+
data_obj["parameters"] = [
|
|
2097
|
+
(
|
|
2098
|
+
{str(k): str(v) for k, v in p.items() if v is not None}
|
|
2099
|
+
if isinstance(p, dict)
|
|
2100
|
+
else {}
|
|
2101
|
+
)
|
|
2102
|
+
for p in params
|
|
2103
|
+
]
|
|
2104
|
+
if data_obj:
|
|
2105
|
+
step_obj["data"] = data_obj
|
|
2106
|
+
|
|
2107
|
+
# inputs / outputs (list of {name, value})
|
|
2108
|
+
for field in ("inputs", "outputs"):
|
|
2109
|
+
vals = step_cfg.get(field)
|
|
2110
|
+
if isinstance(vals, list):
|
|
2111
|
+
step_obj[field] = vals
|
|
2112
|
+
|
|
2113
|
+
# properties (string key-value map)
|
|
2114
|
+
step_props = step_cfg.get("properties")
|
|
2115
|
+
if isinstance(step_props, dict):
|
|
2116
|
+
step_obj["properties"] = {str(k): str(v) for k, v in step_props.items()}
|
|
2117
|
+
|
|
2118
|
+
# keywords: inherit from result so steps are cleaned up together
|
|
2119
|
+
kw: List[str] = list(result_keywords)
|
|
2120
|
+
extra_kw = step_cfg.get("keywords")
|
|
2121
|
+
if isinstance(extra_kw, list):
|
|
2122
|
+
kw.extend([str(k) for k in extra_kw])
|
|
2123
|
+
if kw:
|
|
2124
|
+
step_obj["keywords"] = self._deduplicate_keywords(kw)
|
|
2125
|
+
|
|
2126
|
+
# children (nested steps, recursive)
|
|
2127
|
+
children_cfg = step_cfg.get("children")
|
|
2128
|
+
if isinstance(children_cfg, list) and children_cfg:
|
|
2129
|
+
step_obj["children"] = [_build_step(c) for c in children_cfg if isinstance(c, dict)]
|
|
2130
|
+
|
|
2131
|
+
return step_obj
|
|
2132
|
+
|
|
2133
|
+
try:
|
|
2134
|
+
step_url = f"{get_base_url()}/nitestmonitor/v2/steps"
|
|
2135
|
+
step_objs = [_build_step(s) for s in steps if isinstance(s, dict)]
|
|
2136
|
+
if not step_objs:
|
|
2137
|
+
return
|
|
2138
|
+
payload: Dict[str, Any] = {"steps": step_objs, "updateResultTotalTime": True}
|
|
2139
|
+
make_api_request("POST", step_url, payload, handle_errors=False)
|
|
2140
|
+
except Exception as exc:
|
|
2141
|
+
click.echo(
|
|
2142
|
+
f"Warning: failed to create test steps for result {result_id}: {exc}",
|
|
2143
|
+
err=True,
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
def _get_test_result_by_name(self, name: str) -> Optional[str]:
|
|
2147
|
+
"""Look up test results by programName via /nitestmonitor/v2/results.
|
|
2148
|
+
|
|
2149
|
+
Returns first matching result ID in the workspace, None otherwise.
|
|
2150
|
+
"""
|
|
2151
|
+
if not name:
|
|
2152
|
+
return None
|
|
2153
|
+
try:
|
|
2154
|
+
url = f"{get_base_url()}/nitestmonitor/v2/results"
|
|
2155
|
+
resp = make_api_request("GET", url, {}, handle_errors=False)
|
|
2156
|
+
data = resp.json()
|
|
2157
|
+
results = data.get("results") or data
|
|
2158
|
+
if isinstance(results, list):
|
|
2159
|
+
for r in results:
|
|
2160
|
+
if self.workspace_id and str(r.get("workspace", "")) != str(self.workspace_id):
|
|
2161
|
+
continue
|
|
2162
|
+
if str(r.get("programName", "")) == name:
|
|
2163
|
+
rid = r.get("id")
|
|
2164
|
+
if rid:
|
|
2165
|
+
return str(rid)
|
|
2166
|
+
return None
|
|
2167
|
+
except Exception:
|
|
2168
|
+
return None
|
|
2169
|
+
|
|
2170
|
+
def _get_test_result_ids_by_name(self, name: str) -> List[str]:
|
|
2171
|
+
"""Return all test result IDs with exact programName in current workspace."""
|
|
2172
|
+
ids: List[str] = []
|
|
2173
|
+
if not name:
|
|
2174
|
+
return ids
|
|
2175
|
+
try:
|
|
2176
|
+
url = f"{get_base_url()}/nitestmonitor/v2/results"
|
|
2177
|
+
resp = make_api_request("GET", url, {}, handle_errors=False)
|
|
2178
|
+
data = resp.json()
|
|
2179
|
+
results = data.get("results") or data
|
|
2180
|
+
if isinstance(results, list):
|
|
2181
|
+
for r in results:
|
|
2182
|
+
if self.workspace_id and str(r.get("workspace", "")) != str(self.workspace_id):
|
|
2183
|
+
continue
|
|
2184
|
+
if str(r.get("programName", "")) == name:
|
|
2185
|
+
rid = r.get("id")
|
|
2186
|
+
if rid:
|
|
2187
|
+
ids.append(str(rid))
|
|
2188
|
+
except Exception:
|
|
2189
|
+
return ids
|
|
2190
|
+
return ids
|
|
2191
|
+
|
|
2192
|
+
def _delete_test_result(self, props: Dict[str, Any]) -> Optional[str]:
|
|
2193
|
+
"""Delete test result via /nitestmonitor/v2/delete-results using keyword tags.
|
|
2194
|
+
|
|
2195
|
+
Uses POST /v2/query-results with Dynamic Linq filter to find results by keyword.
|
|
2196
|
+
Returns ID if deleted, None otherwise.
|
|
2197
|
+
"""
|
|
2198
|
+
# Build the expected cleanup keyword based on example name
|
|
2199
|
+
example_tag = f"slcli-example:{self.example_name}" if self.example_name else None
|
|
2200
|
+
|
|
2201
|
+
try:
|
|
2202
|
+
# Build filter to match results with slcli-provisioner keyword
|
|
2203
|
+
# Also match example tag if set
|
|
2204
|
+
filter_parts = ['keywords.Any(x => x == "slcli-provisioner")']
|
|
2205
|
+
if example_tag:
|
|
2206
|
+
filter_parts.append(f'keywords.Any(x => x == "{example_tag}")')
|
|
2207
|
+
|
|
2208
|
+
filter_expr = " && ".join(filter_parts)
|
|
2209
|
+
|
|
2210
|
+
# Add workspace filter if set
|
|
2211
|
+
if self.workspace_id:
|
|
2212
|
+
filter_expr += f' && workspace == "{self.workspace_id}"'
|
|
2213
|
+
|
|
2214
|
+
url = f"{get_base_url()}/nitestmonitor/v2/query-results"
|
|
2215
|
+
payload = {
|
|
2216
|
+
"filter": filter_expr,
|
|
2217
|
+
"take": 1000,
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
2221
|
+
data = resp.json()
|
|
2222
|
+
results = data.get("results", [])
|
|
2223
|
+
|
|
2224
|
+
if not results:
|
|
2225
|
+
# If we've already performed the tagged deletion, treat as already deleted
|
|
2226
|
+
if self._test_results_deleted:
|
|
2227
|
+
return "__ALREADY_DELETED__"
|
|
2228
|
+
return None
|
|
2229
|
+
|
|
2230
|
+
# Extract IDs from matching results
|
|
2231
|
+
result_ids: List[str] = []
|
|
2232
|
+
for r in results:
|
|
2233
|
+
rid = r.get("id")
|
|
2234
|
+
if rid:
|
|
2235
|
+
result_ids.append(str(rid))
|
|
2236
|
+
|
|
2237
|
+
if not result_ids:
|
|
2238
|
+
if self._test_results_deleted:
|
|
2239
|
+
return "__ALREADY_DELETED__"
|
|
2240
|
+
return None
|
|
2241
|
+
|
|
2242
|
+
# Delete all matching results
|
|
2243
|
+
delete_url = f"{get_base_url()}/nitestmonitor/v2/delete-results"
|
|
2244
|
+
delete_payload = {"ids": result_ids, "deleteSteps": True}
|
|
2245
|
+
make_api_request("POST", delete_url, delete_payload, handle_errors=False)
|
|
2246
|
+
self._test_results_deleted = True
|
|
2247
|
+
|
|
2248
|
+
# Return a summary string indicating how many results were deleted
|
|
2249
|
+
if len(result_ids) == 1:
|
|
2250
|
+
return result_ids[0]
|
|
2251
|
+
return f"{result_ids[0]} (+{len(result_ids) - 1} more)"
|
|
2252
|
+
except Exception:
|
|
2253
|
+
return None
|
|
2254
|
+
|
|
2255
|
+
# ========================================================================
|
|
2256
|
+
# Data Table Methods (Tier 3)
|
|
2257
|
+
# ========================================================================
|
|
2258
|
+
|
|
2259
|
+
def _create_data_table(self, props: Dict[str, Any]) -> Optional[str]:
|
|
2260
|
+
"""Create data table via /nidataframe/v1/tables.
|
|
2261
|
+
|
|
2262
|
+
Returns table ID if created, None on error.
|
|
2263
|
+
"""
|
|
2264
|
+
name = props.get("name", "")
|
|
2265
|
+
if not name:
|
|
2266
|
+
return None
|
|
2267
|
+
|
|
2268
|
+
try:
|
|
2269
|
+
url = f"{get_base_url()}/nidataframe/v1/tables"
|
|
2270
|
+
# Transform columns: convert 'type' to 'dataType' and add first column as INDEX
|
|
2271
|
+
columns = props.get("columns", [])
|
|
2272
|
+
transformed_cols: list[Dict[str, Any]] = []
|
|
2273
|
+
for idx, col in enumerate(columns):
|
|
2274
|
+
col_def: Dict[str, Any] = {"name": col.get("name", f"col_{idx}")}
|
|
2275
|
+
# Map type -> dataType
|
|
2276
|
+
col_type = col.get("type", "STRING").upper()
|
|
2277
|
+
if col_type == "TIMESTAMP":
|
|
2278
|
+
col_def["dataType"] = "TIMESTAMP"
|
|
2279
|
+
elif col_type == "NUMBER":
|
|
2280
|
+
col_def["dataType"] = "FLOAT64"
|
|
2281
|
+
elif col_type == "STRING":
|
|
2282
|
+
col_def["dataType"] = "STRING"
|
|
2283
|
+
elif col_type == "INT":
|
|
2284
|
+
col_def["dataType"] = "INT64"
|
|
2285
|
+
elif col_type == "BOOL":
|
|
2286
|
+
col_def["dataType"] = "BOOL"
|
|
2287
|
+
else:
|
|
2288
|
+
col_def["dataType"] = "STRING"
|
|
2289
|
+
# First column is INDEX; rest are NORMAL
|
|
2290
|
+
if idx == 0:
|
|
2291
|
+
col_def["columnType"] = "INDEX"
|
|
2292
|
+
# Ensure INDEX has valid type (not FLOAT64)
|
|
2293
|
+
if col_def.get("dataType") == "FLOAT64":
|
|
2294
|
+
# Prefer INT64 for index when numeric
|
|
2295
|
+
col_def["dataType"] = "INT64"
|
|
2296
|
+
transformed_cols.append(col_def)
|
|
2297
|
+
|
|
2298
|
+
payload = {
|
|
2299
|
+
"name": name,
|
|
2300
|
+
"description": props.get("description", ""),
|
|
2301
|
+
"columns": transformed_cols,
|
|
2302
|
+
"properties": props.get("properties", {}),
|
|
2303
|
+
}
|
|
2304
|
+
# Add keywords for precise cleanup
|
|
2305
|
+
kw: List[str] = []
|
|
2306
|
+
if isinstance(props.get("keywords"), list):
|
|
2307
|
+
kw.extend([str(x) for x in props.get("keywords", [])])
|
|
2308
|
+
if isinstance(props.get("tags"), list):
|
|
2309
|
+
kw.extend([str(x) for x in props.get("tags", [])])
|
|
2310
|
+
if self.example_name:
|
|
2311
|
+
kw.append(f"slcli-example:{self.example_name}")
|
|
2312
|
+
if kw:
|
|
2313
|
+
payload["keywords"] = self._deduplicate_keywords(kw)
|
|
2314
|
+
if self.workspace_id:
|
|
2315
|
+
payload["workspace"] = self.workspace_id
|
|
2316
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
2317
|
+
resp.raise_for_status()
|
|
2318
|
+
data = resp.json()
|
|
2319
|
+
# Prefer ID from response if present
|
|
2320
|
+
if data.get("id"):
|
|
2321
|
+
return data.get("id")
|
|
2322
|
+
# Fallback: lookup by name if ID not returned
|
|
2323
|
+
looked_up_id = self._get_data_table_by_name(name)
|
|
2324
|
+
if looked_up_id:
|
|
2325
|
+
return looked_up_id
|
|
2326
|
+
# If still no ID, return a generated reference (for audit purposes)
|
|
2327
|
+
return str(abs(hash(name)) % (10**12))
|
|
2328
|
+
except Exception:
|
|
2329
|
+
return None
|
|
2330
|
+
|
|
2331
|
+
def _get_data_table_by_name(self, name: str) -> Optional[str]:
|
|
2332
|
+
"""Look up data table by name via /nidataframe/v1/query-tables.
|
|
2333
|
+
|
|
2334
|
+
Returns table ID if found, None otherwise.
|
|
2335
|
+
"""
|
|
2336
|
+
if not name:
|
|
2337
|
+
return None
|
|
2338
|
+
|
|
2339
|
+
try:
|
|
2340
|
+
url = f"{get_base_url()}/nidataframe/v1/query-tables"
|
|
2341
|
+
filter_str = f"name == @0"
|
|
2342
|
+
if self.workspace_id:
|
|
2343
|
+
filter_str += f" and workspace == @1"
|
|
2344
|
+
payload = {
|
|
2345
|
+
"filter": filter_str,
|
|
2346
|
+
"substitutions": ([name, self.workspace_id] if self.workspace_id else [name]),
|
|
2347
|
+
"projection": ["NAME", "WORKSPACE"],
|
|
2348
|
+
"take": 100,
|
|
2349
|
+
}
|
|
2350
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
2351
|
+
resp.raise_for_status()
|
|
2352
|
+
data = resp.json()
|
|
2353
|
+
if "tables" in data and len(data["tables"]) > 0:
|
|
2354
|
+
# Find exact case-insensitive match
|
|
2355
|
+
for table in data["tables"]:
|
|
2356
|
+
if table.get("name", "").lower() == name.lower():
|
|
2357
|
+
return table.get("id")
|
|
2358
|
+
return None
|
|
2359
|
+
except Exception:
|
|
2360
|
+
return None
|
|
2361
|
+
|
|
2362
|
+
def _get_data_table_ids_by_name(self, name: str) -> List[str]:
|
|
2363
|
+
"""Return all data table IDs with exact name in current workspace."""
|
|
2364
|
+
ids: List[str] = []
|
|
2365
|
+
if not name:
|
|
2366
|
+
return ids
|
|
2367
|
+
try:
|
|
2368
|
+
url = f"{get_base_url()}/nidataframe/v1/query-tables"
|
|
2369
|
+
filter_str = f"name == @0"
|
|
2370
|
+
subs: List[str] = [name]
|
|
2371
|
+
if self.workspace_id:
|
|
2372
|
+
filter_str += f" and workspace == @1"
|
|
2373
|
+
subs.append(self.workspace_id)
|
|
2374
|
+
payload = {
|
|
2375
|
+
"filter": filter_str,
|
|
2376
|
+
"substitutions": subs,
|
|
2377
|
+
"projection": ["NAME", "WORKSPACE"],
|
|
2378
|
+
"take": 500,
|
|
2379
|
+
}
|
|
2380
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
2381
|
+
resp.raise_for_status()
|
|
2382
|
+
data = resp.json()
|
|
2383
|
+
for table in data.get("tables", []) or []:
|
|
2384
|
+
if str(table.get("name", "")).lower() == name.lower():
|
|
2385
|
+
tid = table.get("id")
|
|
2386
|
+
if tid:
|
|
2387
|
+
ids.append(tid)
|
|
2388
|
+
except Exception:
|
|
2389
|
+
return ids
|
|
2390
|
+
return ids
|
|
2391
|
+
|
|
2392
|
+
def _delete_data_table(self, props: Dict[str, Any]) -> Optional[str]:
|
|
2393
|
+
"""Delete data table via /nidataframe/v1/delete-tables.
|
|
2394
|
+
|
|
2395
|
+
Returns ID if deleted, None otherwise.
|
|
2396
|
+
"""
|
|
2397
|
+
name = props.get("name", "")
|
|
2398
|
+
if not name:
|
|
2399
|
+
return None
|
|
2400
|
+
|
|
2401
|
+
table_ids = self._get_data_table_ids_by_name(name)
|
|
2402
|
+
if not table_ids:
|
|
2403
|
+
return None
|
|
2404
|
+
|
|
2405
|
+
try:
|
|
2406
|
+
url = f"{get_base_url()}/nidataframe/v1/delete-tables"
|
|
2407
|
+
payload = {"ids": table_ids}
|
|
2408
|
+
resp = make_api_request("POST", url, payload, handle_errors=False)
|
|
2409
|
+
resp.raise_for_status()
|
|
2410
|
+
return table_ids[0]
|
|
2411
|
+
except Exception:
|
|
2412
|
+
return None
|
|
2413
|
+
|
|
2414
|
+
# ========================================================================
|
|
2415
|
+
# File Methods (Tier 1 & 3)
|
|
2416
|
+
# ========================================================================
|
|
2417
|
+
|
|
2418
|
+
def _create_file(self, props: Dict[str, Any]) -> Optional[str]:
|
|
2419
|
+
"""Create file via /nifile/v1/service-groups/Default/upload-files (multipart).
|
|
2420
|
+
|
|
2421
|
+
Returns file ID if created, None on error.
|
|
2422
|
+
"""
|
|
2423
|
+
name = props.get("name", "")
|
|
2424
|
+
if not name:
|
|
2425
|
+
return None
|
|
2426
|
+
|
|
2427
|
+
# Handle as regular file upload
|
|
2428
|
+
try:
|
|
2429
|
+
url = f"{get_base_url()}/nifile/v1/service-groups/Default/upload-files"
|
|
2430
|
+
|
|
2431
|
+
# Get file content from file_path if provided, otherwise use placeholder
|
|
2432
|
+
file_path = props.get("file_path")
|
|
2433
|
+
if file_path:
|
|
2434
|
+
file_content = self._read_example_file(file_path)
|
|
2435
|
+
if file_content is None:
|
|
2436
|
+
return None
|
|
2437
|
+
# Extract filename from file_path to preserve extension
|
|
2438
|
+
from pathlib import Path
|
|
2439
|
+
|
|
2440
|
+
file_basename = Path(file_path).name
|
|
2441
|
+
# Append extension to name if not already present and we have a file_path
|
|
2442
|
+
upload_name = name
|
|
2443
|
+
if "." not in upload_name and "." in file_basename:
|
|
2444
|
+
upload_name = f"{name}.{file_basename.split('.')[-1]}"
|
|
2445
|
+
else:
|
|
2446
|
+
# Create minimal file content (placeholder for demo)
|
|
2447
|
+
file_content = (f"# {name}\n# Created by SystemLink example provisioner\n").encode(
|
|
2448
|
+
"utf-8"
|
|
2449
|
+
)
|
|
2450
|
+
upload_name = name
|
|
2451
|
+
|
|
2452
|
+
# Prepare metadata as JSON string
|
|
2453
|
+
# File metadata supports Name, description, and custom properties
|
|
2454
|
+
metadata = {
|
|
2455
|
+
"description": props.get("description", ""),
|
|
2456
|
+
"Name": upload_name, # Use upload_name which includes extension
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
# Build cleanup tags and store in properties
|
|
2460
|
+
kw: List[str] = []
|
|
2461
|
+
if isinstance(props.get("keywords"), list):
|
|
2462
|
+
kw.extend([str(x) for x in props.get("keywords", [])])
|
|
2463
|
+
if isinstance(props.get("tags"), list):
|
|
2464
|
+
kw.extend([str(x) for x in props.get("tags", [])])
|
|
2465
|
+
kw.append("slcli-provisioner")
|
|
2466
|
+
if self.example_name:
|
|
2467
|
+
kw.append(f"slcli-example:{self.example_name}")
|
|
2468
|
+
if kw:
|
|
2469
|
+
# Store tags as comma-separated string in a custom property
|
|
2470
|
+
metadata["slcli-tags"] = ",".join(self._deduplicate_keywords(kw))
|
|
2471
|
+
|
|
2472
|
+
# Prepare multipart form data
|
|
2473
|
+
files = {
|
|
2474
|
+
"file": (
|
|
2475
|
+
upload_name,
|
|
2476
|
+
file_content,
|
|
2477
|
+
props.get("content_type", "application/octet-stream"),
|
|
2478
|
+
)
|
|
2479
|
+
}
|
|
2480
|
+
data = {"metadata": json_module.dumps(metadata)}
|
|
2481
|
+
if self.workspace_id:
|
|
2482
|
+
data["workspace"] = self.workspace_id
|
|
2483
|
+
# Use requests directly for multipart
|
|
2484
|
+
import requests
|
|
2485
|
+
|
|
2486
|
+
headers = get_headers()
|
|
2487
|
+
resp = requests.post(url, files=files, data=data, headers=headers, timeout=30)
|
|
2488
|
+
resp.raise_for_status()
|
|
2489
|
+
response_data = resp.json()
|
|
2490
|
+
# Extract ID from URI or response
|
|
2491
|
+
if "uri" in response_data:
|
|
2492
|
+
# URI format: /nifile/v1/service-groups/Default/files/{id}
|
|
2493
|
+
uri = response_data["uri"]
|
|
2494
|
+
file_id = uri.split("/")[-1]
|
|
2495
|
+
return file_id if file_id else None
|
|
2496
|
+
# Fallback: return None (files don't support name-based lookup)
|
|
2497
|
+
return None
|
|
2498
|
+
except Exception:
|
|
2499
|
+
return None
|
|
2500
|
+
|
|
2501
|
+
def _read_example_file(self, file_path: str) -> Optional[bytes]:
|
|
2502
|
+
"""Read a file from the example directory.
|
|
2503
|
+
|
|
2504
|
+
Args:
|
|
2505
|
+
file_path: Path relative to example directory
|
|
2506
|
+
|
|
2507
|
+
Returns:
|
|
2508
|
+
File contents as bytes, or None if not found.
|
|
2509
|
+
"""
|
|
2510
|
+
from pathlib import Path
|
|
2511
|
+
|
|
2512
|
+
try:
|
|
2513
|
+
if self.example_name:
|
|
2514
|
+
# Path relative to slcli/examples/{example_name}/
|
|
2515
|
+
example_dir = Path(__file__).parent / "examples" / self.example_name
|
|
2516
|
+
full_path = example_dir / file_path
|
|
2517
|
+
else:
|
|
2518
|
+
full_path = Path(file_path)
|
|
2519
|
+
|
|
2520
|
+
if not full_path.exists():
|
|
2521
|
+
click.echo(
|
|
2522
|
+
f"Warning: File not found: {full_path}",
|
|
2523
|
+
err=True,
|
|
2524
|
+
)
|
|
2525
|
+
return None
|
|
2526
|
+
|
|
2527
|
+
with open(full_path, "rb") as f:
|
|
2528
|
+
return f.read()
|
|
2529
|
+
except FileNotFoundError:
|
|
2530
|
+
click.echo(
|
|
2531
|
+
f"Warning: File not found: {file_path}",
|
|
2532
|
+
err=True,
|
|
2533
|
+
)
|
|
2534
|
+
return None
|
|
2535
|
+
except PermissionError:
|
|
2536
|
+
click.echo(
|
|
2537
|
+
f"Warning: Permission denied reading file: {file_path}",
|
|
2538
|
+
err=True,
|
|
2539
|
+
)
|
|
2540
|
+
return None
|
|
2541
|
+
except Exception as exc:
|
|
2542
|
+
click.echo(
|
|
2543
|
+
f"Warning: Error reading file {file_path}: {exc}",
|
|
2544
|
+
err=True,
|
|
2545
|
+
)
|
|
2546
|
+
return None
|
|
2547
|
+
|
|
2548
|
+
def _create_notebook(self, props: Dict[str, Any]) -> Optional[str]:
|
|
2549
|
+
"""Create a notebook from a file path and assign an interface.
|
|
2550
|
+
|
|
2551
|
+
Args:
|
|
2552
|
+
props: Resource properties containing:
|
|
2553
|
+
- name: Notebook name in SystemLink
|
|
2554
|
+
- file_path: Path to .ipynb file relative to example directory
|
|
2555
|
+
- notebook_interface: Notebook interface name (e.g., "File Analysis")
|
|
2556
|
+
|
|
2557
|
+
Returns:
|
|
2558
|
+
Notebook ID if created, None on error.
|
|
2559
|
+
"""
|
|
2560
|
+
name = props.get("name", "")
|
|
2561
|
+
file_path = props.get("file_path", "")
|
|
2562
|
+
interface = props.get("notebook_interface", "")
|
|
2563
|
+
|
|
2564
|
+
if not name or not file_path:
|
|
2565
|
+
return None
|
|
2566
|
+
from pathlib import Path
|
|
2567
|
+
|
|
2568
|
+
try:
|
|
2569
|
+
# Resolve file path relative to example directory
|
|
2570
|
+
if self.example_name:
|
|
2571
|
+
# Path relative to slcli/examples/{example_name}/
|
|
2572
|
+
example_dir = Path(__file__).parent / "examples" / self.example_name
|
|
2573
|
+
notebook_file = example_dir / file_path
|
|
2574
|
+
else:
|
|
2575
|
+
notebook_file = Path(file_path)
|
|
2576
|
+
|
|
2577
|
+
if not notebook_file.exists():
|
|
2578
|
+
return None
|
|
2579
|
+
|
|
2580
|
+
# Read notebook content
|
|
2581
|
+
with open(notebook_file, "rb") as f:
|
|
2582
|
+
content = f.read()
|
|
2583
|
+
|
|
2584
|
+
# Create notebook via multipart API
|
|
2585
|
+
base_url = get_base_url()
|
|
2586
|
+
headers = get_headers()
|
|
2587
|
+
|
|
2588
|
+
# Create metadata following the SystemLink NotebookMetadata model
|
|
2589
|
+
metadata: Dict[str, Any] = {
|
|
2590
|
+
"name": name,
|
|
2591
|
+
"workspace": self.workspace_id or "Default",
|
|
2592
|
+
"properties": {},
|
|
2593
|
+
"parameters": {},
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
# Add example tag for cleanup
|
|
2597
|
+
if self.example_name:
|
|
2598
|
+
metadata["properties"]["slcli-example"] = self.example_name
|
|
2599
|
+
|
|
2600
|
+
metadata_json = json_module.dumps(metadata, separators=(",", ":"))
|
|
2601
|
+
metadata_bytes = metadata_json.encode("utf-8")
|
|
2602
|
+
|
|
2603
|
+
files = {
|
|
2604
|
+
"metadata": ("metadata.json", metadata_bytes, "application/json"),
|
|
2605
|
+
"content": ("notebook.ipynb", content, "application/octet-stream"),
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
# Create the notebook
|
|
2609
|
+
notebook_url = f"{base_url}/ninotebook/v1/notebook"
|
|
2610
|
+
resp = requests.post(
|
|
2611
|
+
notebook_url, headers=headers, files=files, verify=True, timeout=30
|
|
2612
|
+
)
|
|
2613
|
+
resp.raise_for_status()
|
|
2614
|
+
response_data = resp.json()
|
|
2615
|
+
notebook_id = response_data.get("id")
|
|
2616
|
+
|
|
2617
|
+
if not notebook_id:
|
|
2618
|
+
return None
|
|
2619
|
+
|
|
2620
|
+
# Assign the interface
|
|
2621
|
+
if interface:
|
|
2622
|
+
# Merge interface with existing properties to preserve slcli-example tag
|
|
2623
|
+
updated_properties = metadata["properties"].copy()
|
|
2624
|
+
updated_properties["interface"] = interface
|
|
2625
|
+
|
|
2626
|
+
interface_metadata = {
|
|
2627
|
+
"name": name,
|
|
2628
|
+
"workspace": self.workspace_id or "Default",
|
|
2629
|
+
"properties": updated_properties,
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
update_url = f"{base_url}/ninotebook/v1/notebook/{notebook_id}"
|
|
2633
|
+
update_files = {
|
|
2634
|
+
"metadata": (
|
|
2635
|
+
"metadata.json",
|
|
2636
|
+
json_module.dumps(interface_metadata, separators=(",", ":")).encode(
|
|
2637
|
+
"utf-8"
|
|
2638
|
+
),
|
|
2639
|
+
"application/json",
|
|
2640
|
+
)
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
resp = requests.put(
|
|
2644
|
+
update_url, headers=headers, files=update_files, verify=True, timeout=30
|
|
2645
|
+
)
|
|
2646
|
+
resp.raise_for_status()
|
|
2647
|
+
|
|
2648
|
+
return notebook_id
|
|
2649
|
+
except FileNotFoundError:
|
|
2650
|
+
click.echo(
|
|
2651
|
+
f"Warning: Notebook file not found: {file_path}",
|
|
2652
|
+
err=True,
|
|
2653
|
+
)
|
|
2654
|
+
return None
|
|
2655
|
+
except Exception as exc:
|
|
2656
|
+
click.echo(
|
|
2657
|
+
f"Warning: Failed to create notebook {name}: {exc}",
|
|
2658
|
+
err=True,
|
|
2659
|
+
)
|
|
2660
|
+
return None
|
|
2661
|
+
|
|
2662
|
+
def _get_file_by_name(self, name: str) -> Optional[str]:
|
|
2663
|
+
"""Look up file by name.
|
|
2664
|
+
|
|
2665
|
+
Returns file ID if found, None otherwise.
|
|
2666
|
+
Note: Files do not support name-based lookup; always returns None.
|
|
2667
|
+
"""
|
|
2668
|
+
# Files endpoint doesn't support name filtering in LINQ;
|
|
2669
|
+
# return None to skip lookups
|
|
2670
|
+
return None
|
|
2671
|
+
|
|
2672
|
+
def _delete_file(self, props: Dict[str, Any]) -> Optional[str]:
|
|
2673
|
+
"""Delete files via /nifile/v1/service-groups/Default/delete-files using tags.
|
|
2674
|
+
|
|
2675
|
+
Returns an ID summary if deleted, None otherwise.
|
|
2676
|
+
"""
|
|
2677
|
+
example_tag = f"slcli-example:{self.example_name}" if self.example_name else None
|
|
2678
|
+
|
|
2679
|
+
try:
|
|
2680
|
+
deleted_ids: List[str] = []
|
|
2681
|
+
|
|
2682
|
+
# Try to query files by workspace
|
|
2683
|
+
if self.workspace_id and example_tag:
|
|
2684
|
+
# Simple query by workspace only - custom properties may not be queryable
|
|
2685
|
+
filter_expr = f'workspace == "{self.workspace_id}"'
|
|
2686
|
+
|
|
2687
|
+
query_url = f"{get_base_url()}/nifile/v1/service-groups/Default/query-files-linq"
|
|
2688
|
+
query_payload = {"filter": filter_expr, "take": 1000}
|
|
2689
|
+
query_resp = make_api_request("POST", query_url, query_payload, handle_errors=False)
|
|
2690
|
+
files = query_resp.json().get("availableFiles", [])
|
|
2691
|
+
|
|
2692
|
+
# Filter client-side by checking metadata for our tags
|
|
2693
|
+
file_ids: List[str] = []
|
|
2694
|
+
for file_item in files:
|
|
2695
|
+
# Check if this file has our example tag in metadata
|
|
2696
|
+
props_meta = file_item.get("properties", {})
|
|
2697
|
+
tags_str = props_meta.get("slcli-tags", "")
|
|
2698
|
+
if example_tag in tags_str and "slcli-provisioner" in tags_str:
|
|
2699
|
+
fid = file_item.get("id")
|
|
2700
|
+
if fid:
|
|
2701
|
+
file_ids.append(str(fid))
|
|
2702
|
+
|
|
2703
|
+
if file_ids:
|
|
2704
|
+
delete_url = f"{get_base_url()}/nifile/v1/service-groups/Default/delete-files"
|
|
2705
|
+
delete_payload = {"ids": file_ids}
|
|
2706
|
+
make_api_request("POST", delete_url, delete_payload, handle_errors=False)
|
|
2707
|
+
deleted_ids.extend(file_ids)
|
|
2708
|
+
self._files_deleted = True
|
|
2709
|
+
|
|
2710
|
+
if not deleted_ids:
|
|
2711
|
+
if self._files_deleted:
|
|
2712
|
+
return "__ALREADY_DELETED__"
|
|
2713
|
+
return None
|
|
2714
|
+
|
|
2715
|
+
if len(deleted_ids) == 1:
|
|
2716
|
+
return deleted_ids[0]
|
|
2717
|
+
return f"{deleted_ids[0]} (+{len(deleted_ids) - 1} more)"
|
|
2718
|
+
except Exception:
|
|
2719
|
+
return None
|
|
2720
|
+
|
|
2721
|
+
def _delete_notebook(self, props: Dict[str, Any]) -> Optional[str]:
|
|
2722
|
+
"""Delete notebooks via /ninotebook/v1/notebook using tags.
|
|
2723
|
+
|
|
2724
|
+
Returns an ID summary if deleted, None otherwise.
|
|
2725
|
+
"""
|
|
2726
|
+
example_tag = f"slcli-example:{self.example_name}" if self.example_name else None
|
|
2727
|
+
|
|
2728
|
+
try:
|
|
2729
|
+
deleted_ids: List[str] = []
|
|
2730
|
+
|
|
2731
|
+
# Query notebooks by workspace and filter client-side
|
|
2732
|
+
# Note: Notebook API doesn't support querying on custom properties
|
|
2733
|
+
if example_tag and self.workspace_id:
|
|
2734
|
+
base_url = get_base_url()
|
|
2735
|
+
|
|
2736
|
+
# Extract example name from tag
|
|
2737
|
+
example_name = example_tag.split(":")[-1]
|
|
2738
|
+
|
|
2739
|
+
# Query by workspace only
|
|
2740
|
+
filter_str = f'workspace == "{self.workspace_id}"'
|
|
2741
|
+
payload: Dict[str, Any] = {"filter": filter_str, "take": 100}
|
|
2742
|
+
resp = make_api_request(
|
|
2743
|
+
"POST",
|
|
2744
|
+
f"{base_url}/ninotebook/v1/notebook/query",
|
|
2745
|
+
payload,
|
|
2746
|
+
handle_errors=False,
|
|
2747
|
+
)
|
|
2748
|
+
notebooks = resp.json().get("notebooks", [])
|
|
2749
|
+
|
|
2750
|
+
# Filter client-side by checking properties for our example tag
|
|
2751
|
+
for notebook in notebooks:
|
|
2752
|
+
props_meta = notebook.get("properties", {})
|
|
2753
|
+
if props_meta.get("slcli-example") == example_name:
|
|
2754
|
+
nb_id = notebook.get("id")
|
|
2755
|
+
if nb_id:
|
|
2756
|
+
try:
|
|
2757
|
+
# Delete the notebook
|
|
2758
|
+
delete_nb_url = f"{base_url}/ninotebook/v1/notebook/{nb_id}"
|
|
2759
|
+
make_api_request("DELETE", delete_nb_url, handle_errors=False)
|
|
2760
|
+
deleted_ids.append(nb_id)
|
|
2761
|
+
except Exception:
|
|
2762
|
+
pass # Continue deleting other notebooks
|
|
2763
|
+
|
|
2764
|
+
# Mark notebooks as deleted after bulk operation
|
|
2765
|
+
if deleted_ids:
|
|
2766
|
+
self._notebooks_deleted = True
|
|
2767
|
+
|
|
2768
|
+
if not deleted_ids:
|
|
2769
|
+
if self._notebooks_deleted:
|
|
2770
|
+
return "__ALREADY_DELETED__"
|
|
2771
|
+
return None
|
|
2772
|
+
|
|
2773
|
+
if len(deleted_ids) == 1:
|
|
2774
|
+
return deleted_ids[0]
|
|
2775
|
+
return f"{deleted_ids[0]} (+{len(deleted_ids) - 1} more)"
|
|
2776
|
+
except Exception:
|
|
2777
|
+
return None
|