systemlink-cli 1.3.1__py3-none-any.whl

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