systemlink-cli 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/asset_click.py ADDED
@@ -0,0 +1,1289 @@
1
+ """CLI commands for managing SystemLink assets.
2
+
3
+ Provides CLI commands for listing, querying, and managing assets in the
4
+ Asset Management service (niapm v1). Supports filtering by model, serial
5
+ number, bus type, asset type, calibration status, and connection state.
6
+ """
7
+
8
+ import json
9
+ import sys
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ import click
13
+ import questionary
14
+
15
+ from .cli_utils import validate_output_format
16
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
17
+ from .utils import (
18
+ ExitCodes,
19
+ check_readonly_mode,
20
+ format_success,
21
+ get_base_url,
22
+ get_workspace_map,
23
+ handle_api_error,
24
+ make_api_request,
25
+ )
26
+ from .workspace_utils import (
27
+ get_effective_workspace,
28
+ get_workspace_display_name,
29
+ resolve_workspace_filter,
30
+ )
31
+
32
+
33
+ def _get_asset_base_url() -> str:
34
+ """Get the base URL for the Asset Management API."""
35
+ return f"{get_base_url()}/niapm/v1"
36
+
37
+
38
+ def _escape_filter_value(value: str) -> str:
39
+ """Escape double quotes in filter values to prevent injection.
40
+
41
+ Args:
42
+ value: Raw filter value from user input.
43
+
44
+ Returns:
45
+ Escaped value safe for embedding in filter expressions.
46
+ """
47
+ return value.replace('"', '\\"')
48
+
49
+
50
+ def _parse_properties(properties: Tuple[str, ...]) -> Dict[str, str]:
51
+ """Parse key=value property strings into a dictionary.
52
+
53
+ Args:
54
+ properties: Tuple of strings in "key=value" format.
55
+
56
+ Returns:
57
+ Dictionary mapping property keys to values.
58
+
59
+ Raises:
60
+ SystemExit: If any property string is not in key=value format.
61
+ """
62
+ props_dict: Dict[str, str] = {}
63
+ for prop in properties:
64
+ if "=" not in prop:
65
+ click.echo(
66
+ f"✗ Invalid property format: {prop}. Use key=value",
67
+ err=True,
68
+ )
69
+ sys.exit(ExitCodes.INVALID_INPUT)
70
+ key, val = prop.split("=", 1)
71
+ props_dict[key.strip()] = val.strip()
72
+ return props_dict
73
+
74
+
75
+ def _build_asset_filter(
76
+ model: Optional[str] = None,
77
+ serial_number: Optional[str] = None,
78
+ bus_type: Optional[str] = None,
79
+ asset_type: Optional[str] = None,
80
+ calibration_status: Optional[str] = None,
81
+ connected: bool = False,
82
+ workspace_id: Optional[str] = None,
83
+ custom_filter: Optional[str] = None,
84
+ ) -> Optional[str]:
85
+ """Build API filter expression from convenience options.
86
+
87
+ Args:
88
+ model: Filter by model name (contains match).
89
+ serial_number: Filter by serial number (exact match).
90
+ bus_type: Filter by bus type.
91
+ asset_type: Filter by asset type.
92
+ calibration_status: Filter by calibration status.
93
+ connected: Show only connected/present assets.
94
+ workspace_id: Filter by workspace ID.
95
+ custom_filter: Advanced user-provided filter expression.
96
+
97
+ Returns:
98
+ Combined filter expression string, or None if no filters.
99
+ """
100
+ parts: List[str] = []
101
+
102
+ if model:
103
+ escaped = _escape_filter_value(model)
104
+ parts.append(f'ModelName.Contains("{escaped}")')
105
+ if serial_number:
106
+ escaped = _escape_filter_value(serial_number)
107
+ parts.append(f'SerialNumber = "{escaped}"')
108
+ if bus_type:
109
+ parts.append(f'BusType = "{bus_type}"')
110
+ if asset_type:
111
+ parts.append(f'AssetType = "{asset_type}"')
112
+ if calibration_status:
113
+ parts.append(f'CalibrationStatus = "{calibration_status}"')
114
+ if connected:
115
+ parts.append(
116
+ 'Location.AssetState.SystemConnection = "CONNECTED"'
117
+ ' and Location.AssetState.AssetPresence = "PRESENT"'
118
+ )
119
+ if workspace_id:
120
+ escaped = _escape_filter_value(workspace_id)
121
+ parts.append(f'Workspace = "{escaped}"')
122
+ if custom_filter:
123
+ parts.append(custom_filter)
124
+
125
+ return " and ".join(parts) if parts else None
126
+
127
+
128
+ def _query_all_assets(
129
+ filter_expr: Optional[str],
130
+ order_by: Optional[str],
131
+ descending: bool,
132
+ take: Optional[int] = 10000,
133
+ calibratable_only: bool = False,
134
+ ) -> List[Dict[str, Any]]:
135
+ """Query assets using skip/take pagination.
136
+
137
+ Fetches up to ``take`` items (default 10,000 for performance).
138
+
139
+ Args:
140
+ filter_expr: Optional API filter expression.
141
+ order_by: Field to order by.
142
+ descending: Whether to return results in descending order.
143
+ take: Maximum number of items to fetch.
144
+ calibratable_only: Only return calibratable assets.
145
+
146
+ Returns:
147
+ List of asset objects (up to ``take`` count).
148
+ """
149
+ url = f"{_get_asset_base_url()}/query-assets"
150
+ all_assets: List[Dict[str, Any]] = []
151
+ page_size = 1000 # API max per request
152
+ skip = 0
153
+
154
+ while True:
155
+ if take is not None:
156
+ remaining = take - len(all_assets)
157
+ if remaining <= 0:
158
+ break
159
+ batch_size = min(page_size, remaining)
160
+ else:
161
+ batch_size = page_size
162
+
163
+ payload: Dict[str, Any] = {
164
+ "skip": skip,
165
+ "take": batch_size,
166
+ "descending": descending,
167
+ "returnCount": True,
168
+ }
169
+
170
+ if filter_expr:
171
+ payload["filter"] = filter_expr
172
+ if order_by:
173
+ payload["orderBy"] = order_by
174
+ if calibratable_only:
175
+ payload["calibratableOnly"] = True
176
+
177
+ resp = make_api_request("POST", url, payload=payload)
178
+ data = resp.json()
179
+ assets = data.get("assets", []) if isinstance(data, dict) else []
180
+
181
+ all_assets.extend(assets)
182
+ skip += len(assets)
183
+
184
+ # Stop if we got fewer than requested (last page)
185
+ if len(assets) < batch_size:
186
+ break
187
+ if take is not None and len(all_assets) >= take:
188
+ break
189
+
190
+ return all_assets[:take] if take is not None else all_assets
191
+
192
+
193
+ def _fetch_assets_page(
194
+ filter_expr: Optional[str],
195
+ order_by: Optional[str],
196
+ descending: bool,
197
+ take: int,
198
+ skip: int,
199
+ calibratable_only: bool = False,
200
+ ) -> Tuple[List[Dict[str, Any]], int]:
201
+ """Fetch a single page of assets.
202
+
203
+ Args:
204
+ filter_expr: Optional API filter expression.
205
+ order_by: Field to order by.
206
+ descending: Whether to return results in descending order.
207
+ take: Number of items to fetch.
208
+ skip: Number of items to skip.
209
+ calibratable_only: Only return calibratable assets.
210
+
211
+ Returns:
212
+ Tuple of (assets list, total count from server).
213
+ """
214
+ url = f"{_get_asset_base_url()}/query-assets"
215
+ payload: Dict[str, Any] = {
216
+ "skip": skip,
217
+ "take": take,
218
+ "descending": descending,
219
+ "returnCount": True,
220
+ }
221
+
222
+ if filter_expr:
223
+ payload["filter"] = filter_expr
224
+ if order_by:
225
+ payload["orderBy"] = order_by
226
+ if calibratable_only:
227
+ payload["calibratableOnly"] = True
228
+
229
+ resp = make_api_request("POST", url, payload=payload)
230
+ data = resp.json()
231
+
232
+ assets = data.get("assets", []) if isinstance(data, dict) else []
233
+ total_count = data.get("totalCount", 0) if isinstance(data, dict) else 0
234
+
235
+ return assets, total_count
236
+
237
+
238
+ def _handle_asset_interactive_pagination(
239
+ filter_expr: Optional[str],
240
+ order_by: Optional[str],
241
+ descending: bool,
242
+ take: int,
243
+ calibratable_only: bool,
244
+ formatter_func: Any,
245
+ headers: List[str],
246
+ column_widths: List[int],
247
+ empty_message: str,
248
+ ) -> None:
249
+ """Handle interactive skip/take pagination for table output.
250
+
251
+ Args:
252
+ filter_expr: Optional API filter expression.
253
+ order_by: Field to order by.
254
+ descending: Whether to return results in descending order.
255
+ take: Number of items per page.
256
+ calibratable_only: Only return calibratable assets.
257
+ formatter_func: Function to format each item for display.
258
+ headers: Column headers for the table.
259
+ column_widths: Column widths for the table.
260
+ empty_message: Message to display when no items are found.
261
+ """
262
+ skip = 0
263
+ shown_count = 0
264
+
265
+ while True:
266
+ page_items, total_count = _fetch_assets_page(
267
+ filter_expr, order_by, descending, take, skip, calibratable_only
268
+ )
269
+
270
+ if not page_items:
271
+ if shown_count == 0:
272
+ click.echo(empty_message)
273
+ break
274
+
275
+ shown_count += len(page_items)
276
+ skip += len(page_items)
277
+
278
+ mock_resp: Any = FilteredResponse({"assets": page_items})
279
+ UniversalResponseHandler.handle_list_response(
280
+ resp=mock_resp,
281
+ data_key="assets",
282
+ item_name="asset",
283
+ format_output="table",
284
+ formatter_func=formatter_func,
285
+ headers=headers,
286
+ column_widths=column_widths,
287
+ empty_message=empty_message,
288
+ enable_pagination=False,
289
+ page_size=take,
290
+ total_count=total_count,
291
+ shown_count=shown_count,
292
+ )
293
+
294
+ # Flush stdout so the table is visible before prompting
295
+ try:
296
+ sys.stdout.flush()
297
+ except Exception:
298
+ # stdout may be closed or invalid (e.g., when piped); ignore flush errors
299
+ pass
300
+
301
+ # Check if there are more results
302
+ if shown_count >= total_count:
303
+ break
304
+
305
+ if not questionary.confirm("Show next set of results?", default=True).ask():
306
+ break
307
+
308
+
309
+ def _warn_if_large_dataset(
310
+ filter_expr: Optional[str],
311
+ calibratable_only: bool = False,
312
+ ) -> None:
313
+ """Check dataset size and warn user if fetching large number of items.
314
+
315
+ Args:
316
+ filter_expr: Optional API filter expression.
317
+ calibratable_only: Only count calibratable assets.
318
+ """
319
+ url = f"{_get_asset_base_url()}/query-assets"
320
+ payload: Dict[str, Any] = {
321
+ "skip": 0,
322
+ "take": 1,
323
+ "returnCount": True,
324
+ }
325
+
326
+ if filter_expr:
327
+ payload["filter"] = filter_expr
328
+ if calibratable_only:
329
+ payload["calibratableOnly"] = True
330
+
331
+ try:
332
+ resp = make_api_request("POST", url, payload=payload)
333
+ data = resp.json()
334
+ total_count = data.get("totalCount", 0) if isinstance(data, dict) else 0
335
+
336
+ if total_count > 10000:
337
+ click.echo(
338
+ f"⚠️ Warning: {total_count} items found. Fetching up to 10,000...",
339
+ err=True,
340
+ )
341
+ elif total_count > 1000:
342
+ click.echo(
343
+ f"ℹ️ Fetching {total_count} items...",
344
+ err=True,
345
+ )
346
+ except Exception:
347
+ # Best-effort warning: if we cannot determine total count
348
+ # (e.g., network error), continue without the size warning.
349
+ pass
350
+
351
+
352
+ def _get_asset_location_display(asset: Dict[str, Any]) -> str:
353
+ """Get a display string for an asset's location.
354
+
355
+ Args:
356
+ asset: Asset dictionary.
357
+
358
+ Returns:
359
+ Location display string.
360
+ """
361
+ location = asset.get("location")
362
+ if not isinstance(location, dict):
363
+ return ""
364
+
365
+ minion_id = location.get("minionId", "")
366
+ physical = location.get("physicalLocation", "")
367
+ slot = location.get("slotNumber")
368
+
369
+ display = minion_id or physical
370
+ if slot is not None:
371
+ display = f"{display} (Slot {slot})" if display else f"Slot {slot}"
372
+
373
+ return display
374
+
375
+
376
+ def _format_asset_detail(asset: Dict[str, Any], workspace_map: Dict[str, str]) -> None:
377
+ """Format and display detailed asset information.
378
+
379
+ Args:
380
+ asset: Asset dictionary.
381
+ workspace_map: Workspace ID to name mapping.
382
+ """
383
+ name = asset.get("name", "Unknown")
384
+ asset_id = asset.get("id", "")
385
+ click.echo(f"Asset: {name} ({asset_id})")
386
+ click.echo(f" Model: {asset.get('modelName', 'N/A')}")
387
+ click.echo(f" Serial Number: {asset.get('serialNumber', 'N/A')}")
388
+ click.echo(f" Part Number: {asset.get('partNumber', 'N/A')}")
389
+ click.echo(f" Vendor: {asset.get('vendorName', 'N/A')}")
390
+ click.echo(f" Bus Type: {asset.get('busType', 'N/A')}")
391
+ click.echo(f" Asset Type: {asset.get('assetType', 'N/A')}")
392
+ click.echo(f" Firmware: {asset.get('firmwareVersion', 'N/A')}")
393
+ click.echo(f" Hardware: {asset.get('hardwareVersion', 'N/A')}")
394
+
395
+ # Workspace
396
+ ws_id = asset.get("workspace", "")
397
+ ws_name = get_workspace_display_name(ws_id, workspace_map)
398
+ click.echo(f" Workspace: {ws_name} ({ws_id})")
399
+
400
+ # Location
401
+ location = asset.get("location")
402
+ if isinstance(location, dict):
403
+ loc_display = _get_asset_location_display(asset)
404
+ click.echo(f" Location: {loc_display}")
405
+
406
+ state = location.get("state")
407
+ if isinstance(state, dict):
408
+ click.echo(f" Presence: {state.get('assetPresence', 'N/A')}")
409
+ click.echo(f" System Connection: {state.get('systemConnection', 'N/A')}")
410
+
411
+ # Calibration
412
+ click.echo(f" Calibration Status: {asset.get('calibrationStatus', 'N/A')}")
413
+
414
+ ext_cal = asset.get("externalCalibration")
415
+ if isinstance(ext_cal, dict):
416
+ click.echo(f" Last Calibrated: {ext_cal.get('date', 'N/A')}")
417
+ click.echo(f" Next Due: {ext_cal.get('nextRecommendedDate', 'N/A')}")
418
+
419
+ # Keywords
420
+ keywords = asset.get("keywords")
421
+ if keywords and isinstance(keywords, list):
422
+ click.echo(f" Keywords: {', '.join(str(k) for k in keywords)}")
423
+
424
+ # Properties
425
+ properties = asset.get("properties")
426
+ if properties and isinstance(properties, dict):
427
+ click.echo(" Properties:")
428
+ for key, value in properties.items():
429
+ click.echo(f" {key}: {value}")
430
+
431
+
432
+ def register_asset_commands(cli: Any) -> None:
433
+ """Register the 'asset' command group and its subcommands.
434
+
435
+ Args:
436
+ cli: Click CLI group to register commands on.
437
+ """
438
+
439
+ @cli.group()
440
+ def asset() -> None:
441
+ """Manage SystemLink assets.
442
+
443
+ Query, inspect, and manage hardware assets tracked by the Asset
444
+ Management service. Supports filtering by model, serial number, bus
445
+ type, calibration status, and connection state.
446
+
447
+ Filter syntax uses the Asset API expression language:
448
+ ModelName.Contains("PXI"), SerialNumber = "01BB877A",
449
+ BusType = "PCI_PXI", and/or operators.
450
+ """
451
+
452
+ # ------------------------------------------------------------------
453
+ # Phase 1: list, get, summary
454
+ # ------------------------------------------------------------------
455
+
456
+ @asset.command(name="list")
457
+ @click.option(
458
+ "--format",
459
+ "-f",
460
+ type=click.Choice(["table", "json"]),
461
+ default="table",
462
+ show_default=True,
463
+ help="Output format",
464
+ )
465
+ @click.option(
466
+ "--take",
467
+ "-t",
468
+ type=int,
469
+ default=25,
470
+ show_default=True,
471
+ help="Items per page (table output only)",
472
+ )
473
+ @click.option("--model", help="Filter by model name (contains match)")
474
+ @click.option("--serial-number", help="Filter by serial number (exact match)")
475
+ @click.option(
476
+ "--bus-type",
477
+ type=click.Choice(
478
+ ["BUILT_IN_SYSTEM", "PCI_PXI", "USB", "GPIB", "VXI", "SERIAL", "TCP_IP", "CRIO"],
479
+ case_sensitive=True,
480
+ ),
481
+ help="Filter by bus type",
482
+ )
483
+ @click.option(
484
+ "--asset-type",
485
+ type=click.Choice(
486
+ ["GENERIC", "DEVICE_UNDER_TEST", "FIXTURE", "SYSTEM"],
487
+ case_sensitive=True,
488
+ ),
489
+ help="Filter by asset type",
490
+ )
491
+ @click.option(
492
+ "--calibration-status",
493
+ type=click.Choice(
494
+ [
495
+ "OK",
496
+ "APPROACHING_RECOMMENDED_DUE_DATE",
497
+ "PAST_RECOMMENDED_DUE_DATE",
498
+ "OUT_FOR_CALIBRATION",
499
+ ],
500
+ case_sensitive=True,
501
+ ),
502
+ help="Filter by calibration status",
503
+ )
504
+ @click.option(
505
+ "--connected",
506
+ is_flag=True,
507
+ help="Show only assets in connected systems (CONNECTED + PRESENT)",
508
+ )
509
+ @click.option(
510
+ "--calibratable",
511
+ is_flag=True,
512
+ help="Show only calibratable assets",
513
+ )
514
+ @click.option("--workspace", "-w", help="Filter by workspace name or ID")
515
+ @click.option(
516
+ "--filter",
517
+ "filter_query",
518
+ help=(
519
+ "Advanced API filter expression "
520
+ '(e.g., \'ModelName.Contains("PXI") and BusType = "PCI_PXI"\')'
521
+ ),
522
+ )
523
+ @click.option(
524
+ "--order-by",
525
+ type=click.Choice(["LAST_UPDATED_TIMESTAMP", "ID"], case_sensitive=False),
526
+ help="Order by field",
527
+ )
528
+ @click.option(
529
+ "--descending/--ascending",
530
+ default=True,
531
+ help="Sort order (default: descending)",
532
+ )
533
+ @click.option(
534
+ "--summary",
535
+ is_flag=True,
536
+ help="Show summary statistics instead of listing assets",
537
+ )
538
+ def list_assets(
539
+ format: str,
540
+ take: int,
541
+ model: Optional[str],
542
+ serial_number: Optional[str],
543
+ bus_type: Optional[str],
544
+ asset_type: Optional[str],
545
+ calibration_status: Optional[str],
546
+ connected: bool,
547
+ calibratable: bool,
548
+ workspace: Optional[str],
549
+ filter_query: Optional[str],
550
+ order_by: Optional[str],
551
+ descending: bool,
552
+ summary: bool,
553
+ ) -> None:
554
+ """List and query assets with optional filtering.
555
+
556
+ Supports convenience filters (--model, --serial-number, --bus-type,
557
+ etc.) that are translated to API filter expressions. Combine multiple
558
+ options — they are joined with 'and'.
559
+
560
+ For advanced queries use --filter with the Asset API filter syntax:
561
+ ModelName.Contains("PXI") and BusType = "PCI_PXI"
562
+ """
563
+ format_output = validate_output_format(format)
564
+
565
+ try:
566
+ # Resolve workspace if provided
567
+ workspace_id: Optional[str] = None
568
+ try:
569
+ workspace_map = get_workspace_map()
570
+ except Exception:
571
+ workspace_map = {}
572
+
573
+ workspace = get_effective_workspace(workspace)
574
+ if workspace:
575
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
576
+
577
+ filter_expr = _build_asset_filter(
578
+ model=model,
579
+ serial_number=serial_number,
580
+ bus_type=bus_type,
581
+ asset_type=asset_type,
582
+ calibration_status=calibration_status,
583
+ connected=connected,
584
+ workspace_id=workspace_id,
585
+ custom_filter=filter_query,
586
+ )
587
+
588
+ if order_by:
589
+ order_by = order_by.upper()
590
+
591
+ def asset_formatter(item: Dict[str, Any]) -> List[str]:
592
+ ws_id = item.get("workspace", "")
593
+ ws_name = get_workspace_display_name(ws_id, workspace_map)
594
+ return [
595
+ item.get("name", ""),
596
+ item.get("modelName", ""),
597
+ item.get("serialNumber", ""),
598
+ item.get("busType", ""),
599
+ item.get("calibrationStatus", ""),
600
+ _get_asset_location_display(item),
601
+ ws_name,
602
+ item.get("id", ""),
603
+ ]
604
+
605
+ headers = [
606
+ "Name",
607
+ "Model",
608
+ "Serial Number",
609
+ "Bus Type",
610
+ "Calibration",
611
+ "Location",
612
+ "Workspace",
613
+ "ID",
614
+ ]
615
+ column_widths = [24, 20, 16, 12, 16, 16, 16, 36]
616
+
617
+ if format_output.lower() == "json":
618
+ _warn_if_large_dataset(filter_expr, calibratable)
619
+ assets = _query_all_assets(
620
+ filter_expr, order_by, descending, calibratable_only=calibratable
621
+ )
622
+
623
+ if summary:
624
+ summary_stats = _summarize_assets(assets)
625
+ click.echo(json.dumps(summary_stats, indent=2))
626
+ else:
627
+ mock_resp: Any = FilteredResponse({"assets": assets})
628
+ UniversalResponseHandler.handle_list_response(
629
+ resp=mock_resp,
630
+ data_key="assets",
631
+ item_name="asset",
632
+ format_output=format_output,
633
+ formatter_func=asset_formatter,
634
+ headers=headers,
635
+ column_widths=column_widths,
636
+ empty_message="No assets found.",
637
+ enable_pagination=False,
638
+ page_size=take,
639
+ )
640
+ else:
641
+ if summary:
642
+ _warn_if_large_dataset(filter_expr, calibratable)
643
+ all_assets = _query_all_assets(
644
+ filter_expr, order_by, descending, calibratable_only=calibratable
645
+ )
646
+ summary_stats = _summarize_assets(all_assets)
647
+ click.echo("\nAsset Summary Statistics:")
648
+ click.echo(f" Total Assets: {summary_stats['total']}")
649
+ click.echo(
650
+ f" Bus Types: {', '.join(summary_stats.get('busTypes', {}).keys()) or 'N/A'}"
651
+ )
652
+ if summary_stats.get("truncated"):
653
+ click.echo(f" Note: {summary_stats['note']}", err=True)
654
+ click.echo()
655
+ else:
656
+ _handle_asset_interactive_pagination(
657
+ filter_expr=filter_expr,
658
+ order_by=order_by,
659
+ descending=descending,
660
+ take=take,
661
+ calibratable_only=calibratable,
662
+ formatter_func=asset_formatter,
663
+ headers=headers,
664
+ column_widths=column_widths,
665
+ empty_message="No assets found.",
666
+ )
667
+ except Exception as exc: # noqa: BLE001
668
+ handle_api_error(exc)
669
+
670
+ @asset.command(name="get")
671
+ @click.argument("asset_id")
672
+ @click.option(
673
+ "--format",
674
+ "-f",
675
+ type=click.Choice(["table", "json"]),
676
+ default="table",
677
+ show_default=True,
678
+ help="Output format",
679
+ )
680
+ @click.option(
681
+ "--include-calibration",
682
+ is_flag=True,
683
+ help="Include calibration history in output",
684
+ )
685
+ def get_asset(
686
+ asset_id: str,
687
+ format: str,
688
+ include_calibration: bool,
689
+ ) -> None:
690
+ """Get detailed information about a specific asset.
691
+
692
+ ASSET_ID is the unique identifier of the asset.
693
+ """
694
+ format_output = validate_output_format(format)
695
+
696
+ try:
697
+ url = f"{_get_asset_base_url()}/assets/{asset_id}"
698
+ resp = make_api_request("GET", url)
699
+ asset_data = resp.json()
700
+
701
+ # Optionally fetch calibration history
702
+ if include_calibration:
703
+ try:
704
+ cal_url = f"{_get_asset_base_url()}/assets/{asset_id}/history/calibration"
705
+ cal_resp = make_api_request("GET", cal_url)
706
+ cal_data = cal_resp.json()
707
+ cal_entries = (
708
+ cal_data.get("calibrationHistory", []) if isinstance(cal_data, dict) else []
709
+ )
710
+ asset_data["calibrationHistory"] = cal_entries
711
+ except Exception:
712
+ asset_data["calibrationHistory"] = []
713
+
714
+ if format_output.lower() == "json":
715
+ click.echo(json.dumps(asset_data, indent=2))
716
+ else:
717
+ try:
718
+ workspace_map = get_workspace_map()
719
+ except Exception:
720
+ workspace_map = {}
721
+ _format_asset_detail(asset_data, workspace_map)
722
+
723
+ if include_calibration and asset_data.get("calibrationHistory"):
724
+ click.echo("\nCalibration History:")
725
+ for entry in asset_data["calibrationHistory"]:
726
+ date = entry.get("date", "N/A")
727
+ entry_type = entry.get("entryType", "N/A")
728
+ click.echo(f" {date} — {entry_type}")
729
+
730
+ except Exception as exc: # noqa: BLE001
731
+ handle_api_error(exc)
732
+
733
+ @asset.command(name="summary")
734
+ @click.option(
735
+ "--format",
736
+ "-f",
737
+ type=click.Choice(["table", "json"]),
738
+ default="table",
739
+ show_default=True,
740
+ help="Output format",
741
+ )
742
+ def asset_summary(format: str) -> None:
743
+ """Show fleet-wide asset summary statistics.
744
+
745
+ Displays counts for total, active, in-use assets and calibration
746
+ status breakdown.
747
+ """
748
+ format_output = validate_output_format(format)
749
+
750
+ try:
751
+ url = f"{_get_asset_base_url()}/asset-summary"
752
+ resp = make_api_request("GET", url)
753
+ data = resp.json()
754
+
755
+ if format_output.lower() == "json":
756
+ click.echo(json.dumps(data, indent=2))
757
+ else:
758
+ click.echo("\nAsset Fleet Summary:")
759
+ click.echo(f" Total Assets: {data.get('total', 0)}")
760
+ click.echo(f" Active (in connected system): {data.get('active', 0)}")
761
+ click.echo(f" Not Active: {data.get('notActive', 0)}")
762
+ click.echo(f" In Use: {data.get('inUse', 0)}")
763
+ click.echo(f" Not In Use: {data.get('notInUse', 0)}")
764
+ click.echo(f" With Alarms: {data.get('withAlarms', 0)}")
765
+ click.echo("\nCalibration Status:")
766
+ click.echo(
767
+ f" Approaching Due Date: " f"{data.get('approachingRecommendedDueDate', 0)}"
768
+ )
769
+ click.echo(f" Past Due Date: {data.get('pastRecommendedDueDate', 0)}")
770
+ click.echo(f" Out for Calibration: {data.get('outForCalibration', 0)}")
771
+ click.echo(f" Total Calibratable: {data.get('totalCalibrated', 0)}")
772
+ click.echo()
773
+
774
+ except Exception as exc: # noqa: BLE001
775
+ handle_api_error(exc)
776
+
777
+ # ------------------------------------------------------------------
778
+ # Phase 2: calibration, location-history
779
+ # ------------------------------------------------------------------
780
+
781
+ @asset.command(name="calibration")
782
+ @click.argument("asset_id")
783
+ @click.option(
784
+ "--format",
785
+ "-f",
786
+ type=click.Choice(["table", "json"]),
787
+ default="table",
788
+ show_default=True,
789
+ help="Output format",
790
+ )
791
+ @click.option(
792
+ "--take",
793
+ "-t",
794
+ type=int,
795
+ default=25,
796
+ show_default=True,
797
+ help="Number of history entries to return",
798
+ )
799
+ def asset_calibration(
800
+ asset_id: str,
801
+ format: str,
802
+ take: int,
803
+ ) -> None:
804
+ """Get calibration history for a specific asset.
805
+
806
+ ASSET_ID is the unique identifier of the asset.
807
+ """
808
+ format_output = validate_output_format(format)
809
+
810
+ try:
811
+ url = (
812
+ f"{_get_asset_base_url()}/assets/{asset_id}/history/calibration"
813
+ f"?Skip=0&Take={take}"
814
+ )
815
+ resp = make_api_request("GET", url)
816
+ data = resp.json()
817
+
818
+ entries = data.get("calibrationHistory", []) if isinstance(data, dict) else []
819
+
820
+ def calibration_formatter(item: Dict[str, Any]) -> List[str]:
821
+ return [
822
+ item.get("date", ""),
823
+ item.get("entryType", ""),
824
+ str(item.get("isLimited", "")),
825
+ item.get("resolvedDueDate", ""),
826
+ str(item.get("recommendedInterval", "")),
827
+ item.get("comments", ""),
828
+ ]
829
+
830
+ if format_output.lower() == "json":
831
+ click.echo(json.dumps(entries, indent=2))
832
+ else:
833
+ mock_resp: Any = FilteredResponse({"calibrationHistory": entries})
834
+ UniversalResponseHandler.handle_list_response(
835
+ resp=mock_resp,
836
+ data_key="calibrationHistory",
837
+ item_name="calibration entry",
838
+ format_output=format_output,
839
+ formatter_func=calibration_formatter,
840
+ headers=[
841
+ "Date",
842
+ "Type",
843
+ "Limited",
844
+ "Next Due",
845
+ "Interval (mo)",
846
+ "Comments",
847
+ ],
848
+ column_widths=[20, 12, 8, 20, 14, 30],
849
+ empty_message="No calibration history found.",
850
+ enable_pagination=True,
851
+ page_size=take,
852
+ )
853
+
854
+ except Exception as exc: # noqa: BLE001
855
+ handle_api_error(exc)
856
+
857
+ @asset.command(name="location-history")
858
+ @click.argument("asset_id")
859
+ @click.option(
860
+ "--format",
861
+ "-f",
862
+ type=click.Choice(["table", "json"]),
863
+ default="table",
864
+ show_default=True,
865
+ help="Output format",
866
+ )
867
+ @click.option(
868
+ "--take",
869
+ "-t",
870
+ type=int,
871
+ default=25,
872
+ show_default=True,
873
+ help="Number of history entries to return",
874
+ )
875
+ @click.option(
876
+ "--from",
877
+ "date_from",
878
+ type=str,
879
+ default=None,
880
+ help="Start of date range (ISO-8601, e.g., 2025-12-01T00:00:00Z)",
881
+ )
882
+ @click.option(
883
+ "--to",
884
+ "date_to",
885
+ type=str,
886
+ default=None,
887
+ help="End of date range (ISO-8601, e.g., 2025-12-02T00:00:00Z)",
888
+ )
889
+ def asset_location_history(
890
+ asset_id: str,
891
+ format: str,
892
+ take: int,
893
+ date_from: Optional[str],
894
+ date_to: Optional[str],
895
+ ) -> None:
896
+ """Get location/connection history for a specific asset.
897
+
898
+ ASSET_ID is the unique identifier of the asset.
899
+
900
+ Use --from and --to for temporal correlation (e.g., confirming an
901
+ asset was present in a system at the time of a test).
902
+ """
903
+ format_output = validate_output_format(format)
904
+
905
+ try:
906
+ url = f"{_get_asset_base_url()}/assets/{asset_id}/history/query-location"
907
+ payload: Dict[str, Any] = {
908
+ "skip": 0,
909
+ "take": take,
910
+ }
911
+ if date_from:
912
+ payload["startTimestamp"] = date_from
913
+ if date_to:
914
+ payload["endTimestamp"] = date_to
915
+
916
+ resp = make_api_request("POST", url, payload=payload)
917
+ data = resp.json()
918
+
919
+ entries = data.get("connectionHistory", []) if isinstance(data, dict) else []
920
+
921
+ def location_formatter(item: Dict[str, Any]) -> List[str]:
922
+ return [
923
+ item.get("timestamp", ""),
924
+ item.get("minionId", ""),
925
+ str(item.get("slotNumber", "")),
926
+ item.get("systemConnection", ""),
927
+ item.get("assetPresence", ""),
928
+ ]
929
+
930
+ if format_output.lower() == "json":
931
+ click.echo(json.dumps(entries, indent=2))
932
+ else:
933
+ mock_resp: Any = FilteredResponse({"connectionHistory": entries})
934
+ UniversalResponseHandler.handle_list_response(
935
+ resp=mock_resp,
936
+ data_key="connectionHistory",
937
+ item_name="location entry",
938
+ format_output=format_output,
939
+ formatter_func=location_formatter,
940
+ headers=["Timestamp", "Minion ID", "Slot", "Connection", "Presence"],
941
+ column_widths=[24, 30, 6, 14, 12],
942
+ empty_message="No location history found.",
943
+ enable_pagination=True,
944
+ page_size=take,
945
+ )
946
+
947
+ except Exception as exc: # noqa: BLE001
948
+ handle_api_error(exc)
949
+
950
+ # ------------------------------------------------------------------
951
+ # Phase 3: create, update, delete (mutations)
952
+ # ------------------------------------------------------------------
953
+
954
+ @asset.command(name="create")
955
+ @click.option("--model-name", required=True, help="Model name of the asset")
956
+ @click.option("--model-number", type=int, default=None, help="Model number")
957
+ @click.option("--serial-number", default=None, help="Serial number")
958
+ @click.option("--vendor-name", default=None, help="Vendor name")
959
+ @click.option("--vendor-number", type=int, default=None, help="Vendor number")
960
+ @click.option("--part-number", default=None, help="Part number")
961
+ @click.option("--name", "asset_name", default=None, help="Display name for the asset")
962
+ @click.option(
963
+ "--bus-type",
964
+ type=click.Choice(
965
+ ["BUILT_IN_SYSTEM", "PCI_PXI", "USB", "GPIB", "VXI", "SERIAL", "TCP_IP", "CRIO"],
966
+ case_sensitive=True,
967
+ ),
968
+ default=None,
969
+ help="Bus type",
970
+ )
971
+ @click.option(
972
+ "--asset-type",
973
+ type=click.Choice(
974
+ ["GENERIC", "DEVICE_UNDER_TEST", "FIXTURE", "SYSTEM"],
975
+ case_sensitive=True,
976
+ ),
977
+ default=None,
978
+ help="Asset type",
979
+ )
980
+ @click.option("--firmware-version", default=None, help="Firmware version")
981
+ @click.option("--hardware-version", default=None, help="Hardware version")
982
+ @click.option("--workspace", "-w", default=None, help="Workspace name or ID")
983
+ @click.option(
984
+ "--keyword",
985
+ "keywords",
986
+ multiple=True,
987
+ help="Keyword to associate (repeatable)",
988
+ )
989
+ @click.option(
990
+ "--property",
991
+ "properties",
992
+ multiple=True,
993
+ help="Property in key=value format (repeatable)",
994
+ )
995
+ @click.option(
996
+ "--format",
997
+ "-f",
998
+ type=click.Choice(["table", "json"]),
999
+ default="table",
1000
+ show_default=True,
1001
+ help="Output format",
1002
+ )
1003
+ def create_asset(
1004
+ model_name: str,
1005
+ model_number: Optional[int],
1006
+ serial_number: Optional[str],
1007
+ vendor_name: Optional[str],
1008
+ vendor_number: Optional[int],
1009
+ part_number: Optional[str],
1010
+ asset_name: Optional[str],
1011
+ bus_type: Optional[str],
1012
+ asset_type: Optional[str],
1013
+ firmware_version: Optional[str],
1014
+ hardware_version: Optional[str],
1015
+ workspace: Optional[str],
1016
+ keywords: Tuple[str, ...],
1017
+ properties: Tuple[str, ...],
1018
+ format: str,
1019
+ ) -> None:
1020
+ """Create a new asset.
1021
+
1022
+ Requires at minimum a --model-name. Additional fields can be set
1023
+ via options.
1024
+ """
1025
+ check_readonly_mode("create an asset")
1026
+ format_output = validate_output_format(format)
1027
+
1028
+ try:
1029
+ asset_data: Dict[str, Any] = {
1030
+ "modelName": model_name,
1031
+ "location": {
1032
+ "state": {"assetPresence": "UNKNOWN"},
1033
+ },
1034
+ }
1035
+
1036
+ if model_number:
1037
+ asset_data["modelNumber"] = model_number
1038
+ if serial_number:
1039
+ asset_data["serialNumber"] = serial_number
1040
+ if vendor_name:
1041
+ asset_data["vendorName"] = vendor_name
1042
+ if vendor_number:
1043
+ asset_data["vendorNumber"] = vendor_number
1044
+ if part_number:
1045
+ asset_data["partNumber"] = part_number
1046
+ if asset_name:
1047
+ asset_data["name"] = asset_name
1048
+ if bus_type:
1049
+ asset_data["busType"] = bus_type
1050
+ if asset_type:
1051
+ asset_data["assetType"] = asset_type
1052
+ if firmware_version:
1053
+ asset_data["firmwareVersion"] = firmware_version
1054
+ if hardware_version:
1055
+ asset_data["hardwareVersion"] = hardware_version
1056
+
1057
+ # Resolve workspace
1058
+ workspace = get_effective_workspace(workspace)
1059
+ if workspace:
1060
+ try:
1061
+ ws_map = get_workspace_map()
1062
+ ws_id = resolve_workspace_filter(workspace, ws_map)
1063
+ asset_data["workspace"] = ws_id
1064
+ except Exception:
1065
+ asset_data["workspace"] = workspace
1066
+
1067
+ if keywords:
1068
+ asset_data["keywords"] = list(keywords)
1069
+
1070
+ if properties:
1071
+ asset_data["properties"] = _parse_properties(properties)
1072
+
1073
+ url = f"{_get_asset_base_url()}/assets"
1074
+ payload: Dict[str, Any] = {"assets": [asset_data]}
1075
+ resp = make_api_request("POST", url, payload=payload)
1076
+ result_data = resp.json()
1077
+
1078
+ # Check if creation was successful
1079
+ assets = result_data.get("assets", [])
1080
+ failed = result_data.get("failed", [])
1081
+
1082
+ if format_output.lower() == "json":
1083
+ click.echo(json.dumps(result_data, indent=2))
1084
+ # Exit with error if any assets failed (complete or partial failure)
1085
+ if failed:
1086
+ sys.exit(ExitCodes.GENERAL_ERROR)
1087
+ else:
1088
+ if assets and failed:
1089
+ # Partial success: show success but warn about failures
1090
+ format_success(
1091
+ "Asset created",
1092
+ {"Model": model_name, "Serial": serial_number or "N/A"},
1093
+ )
1094
+ error_info = failed[0] if failed else {}
1095
+ error_msg = error_info.get("error", {}).get("message", "Unknown error")
1096
+ click.echo(f"⚠ Warning: Some assets failed to create: {error_msg}", err=True)
1097
+ sys.exit(ExitCodes.GENERAL_ERROR)
1098
+ elif assets:
1099
+ # Complete success
1100
+ format_success(
1101
+ "Asset created",
1102
+ {"Model": model_name, "Serial": serial_number or "N/A"},
1103
+ )
1104
+ elif failed:
1105
+ # Complete failure
1106
+ error_info = failed[0] if failed else {}
1107
+ error_msg = error_info.get("error", {}).get("message", "Unknown error")
1108
+ click.echo(f"✗ Asset creation failed: {error_msg}", err=True)
1109
+ sys.exit(ExitCodes.GENERAL_ERROR)
1110
+ else:
1111
+ # Edge case: empty response
1112
+ format_success(
1113
+ "Asset created",
1114
+ {"Model": model_name, "Serial": serial_number or "N/A"},
1115
+ )
1116
+
1117
+ except Exception as exc: # noqa: BLE001
1118
+ handle_api_error(exc)
1119
+
1120
+ @asset.command(name="update")
1121
+ @click.argument("asset_id")
1122
+ @click.option("--name", "asset_name", default=None, help="Update display name")
1123
+ @click.option("--model-name", default=None, help="Update model name")
1124
+ @click.option("--model-number", default=None, help="Update model number")
1125
+ @click.option("--serial-number", default=None, help="Update serial number")
1126
+ @click.option("--vendor-name", default=None, help="Update vendor name")
1127
+ @click.option("--part-number", default=None, help="Update part number")
1128
+ @click.option("--firmware-version", default=None, help="Update firmware version")
1129
+ @click.option("--hardware-version", default=None, help="Update hardware version")
1130
+ @click.option(
1131
+ "--keyword",
1132
+ "keywords",
1133
+ multiple=True,
1134
+ help="Replace keywords (repeatable)",
1135
+ )
1136
+ @click.option(
1137
+ "--property",
1138
+ "properties",
1139
+ multiple=True,
1140
+ help="Replace properties in key=value format (repeatable)",
1141
+ )
1142
+ @click.option(
1143
+ "--format",
1144
+ "-f",
1145
+ type=click.Choice(["table", "json"]),
1146
+ default="table",
1147
+ show_default=True,
1148
+ help="Output format",
1149
+ )
1150
+ def update_asset(
1151
+ asset_id: str,
1152
+ asset_name: Optional[str],
1153
+ model_name: Optional[str],
1154
+ model_number: Optional[str],
1155
+ serial_number: Optional[str],
1156
+ vendor_name: Optional[str],
1157
+ part_number: Optional[str],
1158
+ firmware_version: Optional[str],
1159
+ hardware_version: Optional[str],
1160
+ keywords: Tuple[str, ...],
1161
+ properties: Tuple[str, ...],
1162
+ format: str,
1163
+ ) -> None:
1164
+ """Update an existing asset's properties.
1165
+
1166
+ ASSET_ID is the unique identifier of the asset to update.
1167
+ Only the specified fields are changed; others remain unchanged.
1168
+ """
1169
+ check_readonly_mode("update an asset")
1170
+ format_output = validate_output_format(format)
1171
+
1172
+ try:
1173
+ # Build update payload — include ID and only changed fields
1174
+ update_data: Dict[str, Any] = {"id": asset_id}
1175
+
1176
+ if asset_name is not None:
1177
+ update_data["name"] = asset_name
1178
+ if model_name is not None:
1179
+ update_data["modelName"] = model_name
1180
+ if model_number is not None:
1181
+ update_data["modelNumber"] = model_number
1182
+ if serial_number is not None:
1183
+ update_data["serialNumber"] = serial_number
1184
+ if vendor_name is not None:
1185
+ update_data["vendorName"] = vendor_name
1186
+ if part_number is not None:
1187
+ update_data["partNumber"] = part_number
1188
+ if firmware_version is not None:
1189
+ update_data["firmwareVersion"] = firmware_version
1190
+ if hardware_version is not None:
1191
+ update_data["hardwareVersion"] = hardware_version
1192
+
1193
+ if keywords:
1194
+ update_data["keywords"] = list(keywords)
1195
+
1196
+ if properties:
1197
+ update_data["properties"] = _parse_properties(properties)
1198
+
1199
+ url = f"{_get_asset_base_url()}/update-assets"
1200
+ payload: Dict[str, Any] = {"assets": [update_data]}
1201
+ resp = make_api_request("POST", url, payload=payload)
1202
+
1203
+ if format_output.lower() == "json":
1204
+ click.echo(json.dumps(resp.json(), indent=2))
1205
+ else:
1206
+ format_success("Asset updated", {"ID": asset_id})
1207
+
1208
+ except Exception as exc: # noqa: BLE001
1209
+ handle_api_error(exc)
1210
+
1211
+ @asset.command(name="delete")
1212
+ @click.argument("asset_id")
1213
+ @click.option(
1214
+ "--force",
1215
+ is_flag=True,
1216
+ help="Delete without confirmation",
1217
+ )
1218
+ def delete_asset(
1219
+ asset_id: str,
1220
+ force: bool,
1221
+ ) -> None:
1222
+ """Delete an asset.
1223
+
1224
+ ASSET_ID is the unique identifier of the asset to delete.
1225
+ """
1226
+ check_readonly_mode("delete an asset")
1227
+
1228
+ try:
1229
+ # Fetch asset info for confirmation display
1230
+ try:
1231
+ info_url = f"{_get_asset_base_url()}/assets/{asset_id}"
1232
+ info_resp = make_api_request("GET", info_url)
1233
+ info = info_resp.json()
1234
+ display_name = info.get("name") or info.get("modelName") or asset_id
1235
+ except Exception:
1236
+ display_name = asset_id
1237
+
1238
+ if not force:
1239
+ if not questionary.confirm(
1240
+ f"Are you sure you want to delete asset '{display_name}'?",
1241
+ default=False,
1242
+ ).ask():
1243
+ click.echo("Delete cancelled.")
1244
+ sys.exit(ExitCodes.SUCCESS)
1245
+
1246
+ url = f"{_get_asset_base_url()}/delete-assets"
1247
+ payload: Dict[str, Any] = {"ids": [asset_id]}
1248
+ make_api_request("POST", url, payload=payload)
1249
+
1250
+ format_success("Asset deleted", {"Name": display_name, "ID": asset_id})
1251
+
1252
+ except Exception as exc: # noqa: BLE001
1253
+ handle_api_error(exc)
1254
+
1255
+
1256
+ def _summarize_assets(
1257
+ assets: List[Dict[str, Any]],
1258
+ max_items: Optional[int] = 10000,
1259
+ ) -> Dict[str, Any]:
1260
+ """Summarize asset data by aggregating bus types and calibration status.
1261
+
1262
+ Args:
1263
+ assets: List of asset objects.
1264
+ max_items: Maximum items that were fetched.
1265
+
1266
+ Returns:
1267
+ Dictionary with summary statistics.
1268
+ """
1269
+ summary: Dict[str, Any] = {"total": len(assets)}
1270
+
1271
+ if max_items is not None and len(assets) >= max_items:
1272
+ summary["truncated"] = True
1273
+ summary["note"] = f"Results limited to {max_items} items"
1274
+
1275
+ # Group by bus type
1276
+ bus_types: Dict[str, int] = {}
1277
+ for a in assets:
1278
+ bt = str(a.get("busType", "N/A"))
1279
+ bus_types[bt] = bus_types.get(bt, 0) + 1
1280
+ summary["busTypes"] = bus_types
1281
+
1282
+ # Group by calibration status
1283
+ cal_statuses: Dict[str, int] = {}
1284
+ for a in assets:
1285
+ cs = str(a.get("calibrationStatus", "N/A"))
1286
+ cal_statuses[cs] = cal_statuses.get(cs, 0) + 1
1287
+ summary["calibrationStatuses"] = cal_statuses
1288
+
1289
+ return summary