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,1667 @@
1
+ """CLI commands for SystemLink Test Monitor (products and test results)."""
2
+
3
+ import json
4
+ import re
5
+ import sys
6
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
7
+
8
+ import click
9
+ import questionary
10
+
11
+ from .cli_utils import validate_output_format
12
+ from .universal_handlers import FilteredResponse, UniversalResponseHandler
13
+ from .utils import (
14
+ ExitCodes,
15
+ format_success,
16
+ get_base_url,
17
+ get_workspace_map,
18
+ handle_api_error,
19
+ make_api_request,
20
+ )
21
+ from .workspace_utils import (
22
+ get_effective_workspace,
23
+ get_workspace_display_name,
24
+ resolve_workspace_filter,
25
+ )
26
+
27
+
28
+ def _get_testmonitor_base_url() -> str:
29
+ """Get the base URL for the Test Monitor API."""
30
+ return f"{get_base_url()}/nitestmonitor/v2"
31
+
32
+
33
+ def _parse_substitutions(values: Iterable[str]) -> List[Any]:
34
+ """Parse substitution values from CLI inputs.
35
+
36
+ Args:
37
+ values: Iterable of raw substitution strings.
38
+
39
+ Returns:
40
+ Parsed substitution values.
41
+ """
42
+ parsed: List[Any] = []
43
+ for value in values:
44
+ try:
45
+ parsed.append(json.loads(value))
46
+ except (json.JSONDecodeError, TypeError):
47
+ parsed.append(value)
48
+ return parsed
49
+
50
+
51
+ def _offset_substitutions(filter_expr: str, offset: int) -> str:
52
+ """Offset substitution indices in a filter expression.
53
+
54
+ Args:
55
+ filter_expr: Filter expression containing @<index> tokens.
56
+ offset: Offset to add to each index.
57
+
58
+ Returns:
59
+ Updated filter expression with offset indices.
60
+ """
61
+ if offset <= 0:
62
+ return filter_expr
63
+
64
+ def _replace(match: re.Match[str]) -> str:
65
+ return f"@{int(match.group(1)) + offset}"
66
+
67
+ return re.sub(r"@(\d+)", _replace, filter_expr)
68
+
69
+
70
+ def _combine_filter_parts(
71
+ base_filter: Optional[str],
72
+ base_substitutions: List[Any],
73
+ extra_filter: Optional[str],
74
+ extra_substitutions: List[Any],
75
+ ) -> Tuple[Optional[str], List[Any]]:
76
+ """Combine base and extra filters with substitution offset handling.
77
+
78
+ Args:
79
+ base_filter: Filter built from structured options.
80
+ base_substitutions: Substitutions for the base filter.
81
+ extra_filter: User-provided filter expression.
82
+ extra_substitutions: Substitutions for the user filter.
83
+
84
+ Returns:
85
+ Tuple of combined filter expression and substitutions.
86
+ """
87
+ if not extra_filter:
88
+ return base_filter, base_substitutions
89
+
90
+ if base_filter:
91
+ offset_filter = _offset_substitutions(extra_filter, len(base_substitutions))
92
+ combined_filter = f"({base_filter}) && ({offset_filter})"
93
+ combined_subs = base_substitutions + extra_substitutions
94
+ return combined_filter, combined_subs
95
+
96
+ return extra_filter, extra_substitutions
97
+
98
+
99
+ def _append_filter(
100
+ filter_parts: List[str], substitutions: List[Any], expression: str, value: Optional[str]
101
+ ) -> None:
102
+ """Append a filter expression with substitution if value is provided.
103
+
104
+ Args:
105
+ filter_parts: List of filter expressions.
106
+ substitutions: List of substitution values.
107
+ expression: Filter expression format using @{index}.
108
+ value: Optional value to insert as a substitution.
109
+ """
110
+ if value is None or value == "":
111
+ return
112
+ index = len(substitutions)
113
+ filter_parts.append(expression.format(index=index))
114
+ substitutions.append(value)
115
+
116
+
117
+ def _format_date(value: str) -> str:
118
+ """Format an ISO-8601 date-time as a date string.
119
+
120
+ Args:
121
+ value: ISO-8601 date-time string.
122
+
123
+ Returns:
124
+ Date portion of the value, or original value if parsing fails.
125
+ """
126
+ if not value:
127
+ return ""
128
+ if "T" in value:
129
+ return value.split("T", maxsplit=1)[0]
130
+ return value
131
+
132
+
133
+ def _format_duration(value: Any) -> str:
134
+ """Format a duration in seconds.
135
+
136
+ Args:
137
+ value: Duration value.
138
+
139
+ Returns:
140
+ Formatted duration string.
141
+ """
142
+ if isinstance(value, (int, float)):
143
+ return f"{value:.1f}"
144
+ return str(value) if value is not None else ""
145
+
146
+
147
+ def _handle_interactive_pagination(
148
+ fetch_page_func: Any,
149
+ data_key: str,
150
+ item_name: str,
151
+ format_output: str,
152
+ formatter_func: Any,
153
+ headers: List[str],
154
+ column_widths: List[int],
155
+ empty_message: str,
156
+ take: int,
157
+ ) -> None:
158
+ """Handle interactive pagination for table output.
159
+
160
+ Args:
161
+ fetch_page_func: Function that returns (items, continuation_token) tuple.
162
+ data_key: Key to use for data in the mock response.
163
+ item_name: Name of the item type (e.g., "product", "result").
164
+ format_output: Output format ("table" or "json").
165
+ formatter_func: Function to format each item for display.
166
+ headers: Column headers for the table.
167
+ column_widths: Column widths for the table.
168
+ empty_message: Message to display when no items are found.
169
+ take: Number of items per page.
170
+ """
171
+ cont: Optional[str] = None
172
+ shown_count = 0
173
+
174
+ while True:
175
+ page_items, cont = fetch_page_func(cont)
176
+
177
+ if not page_items:
178
+ if shown_count == 0:
179
+ click.echo(empty_message)
180
+ break
181
+
182
+ shown_count += len(page_items)
183
+
184
+ mock_resp = FilteredResponse({data_key: page_items})
185
+ UniversalResponseHandler.handle_list_response(
186
+ resp=mock_resp,
187
+ data_key=data_key,
188
+ item_name=item_name,
189
+ format_output=format_output,
190
+ formatter_func=formatter_func,
191
+ headers=headers,
192
+ column_widths=column_widths,
193
+ empty_message=empty_message,
194
+ enable_pagination=False,
195
+ page_size=take,
196
+ shown_count=shown_count,
197
+ )
198
+
199
+ # Flush stdout so the table is visible before prompting
200
+ try:
201
+ sys.stdout.flush()
202
+ except Exception:
203
+ # Best-effort flush; ignore failures to avoid crashing on I/O issues.
204
+ pass
205
+
206
+ # Ask if user wants to fetch the next page
207
+ if not cont:
208
+ break
209
+
210
+ if not questionary.confirm("Show next set of results?", default=True).ask():
211
+ break
212
+
213
+
214
+ def _warn_if_large_dataset(
215
+ endpoint: str,
216
+ filter_expr: Optional[str],
217
+ substitutions: List[Any],
218
+ product_filter: Optional[str],
219
+ product_substitutions: List[Any],
220
+ order_by: Optional[str] = None,
221
+ descending: bool = False,
222
+ ) -> None:
223
+ """Check dataset size and warn user if fetching large number of items.
224
+
225
+ Args:
226
+ endpoint: API endpoint ("query-products" or "query-results").
227
+ filter_expr: Optional Dynamic LINQ filter expression.
228
+ substitutions: Substitution values for the filter.
229
+ product_filter: Optional product filter (for results only).
230
+ product_substitutions: Product filter substitutions (for results only).
231
+ order_by: Field to order by (included in count check for consistency).
232
+ descending: Whether results should be in descending order.
233
+ """
234
+ url = f"{_get_testmonitor_base_url()}/{endpoint}"
235
+ payload: Dict[str, Any] = {
236
+ "take": 1, # Only fetch 1 item to check count
237
+ "returnCount": True, # Request total count
238
+ "descending": descending,
239
+ }
240
+
241
+ if order_by:
242
+ payload["orderBy"] = order_by
243
+
244
+ if filter_expr:
245
+ payload["filter"] = filter_expr
246
+ if substitutions:
247
+ payload["substitutions"] = substitutions
248
+
249
+ if product_filter:
250
+ payload["productFilter"] = product_filter
251
+ if product_substitutions:
252
+ payload["productSubstitutions"] = product_substitutions
253
+
254
+ try:
255
+ resp = make_api_request("POST", url, payload=payload)
256
+ data = resp.json()
257
+ total_count = data.get("totalCount", 0) if isinstance(data, dict) else 0
258
+
259
+ if total_count > 10000:
260
+ click.echo(
261
+ f"⚠️ Warning: {total_count} items found. Fetching up to 10,000...",
262
+ err=True,
263
+ )
264
+ elif total_count > 1000:
265
+ click.echo(
266
+ f"ℹ️ Fetching {total_count} items...",
267
+ err=True,
268
+ )
269
+ except Exception:
270
+ # If count check fails, proceed without warning
271
+ pass
272
+
273
+
274
+ def _query_all_products(
275
+ filter_expr: Optional[str],
276
+ substitutions: List[Any],
277
+ order_by: Optional[str],
278
+ descending: bool,
279
+ take: Optional[int] = 10000,
280
+ ) -> List[Dict[str, Any]]:
281
+ """Query products using continuation token pagination.
282
+
283
+ Fetches up to take items (default 10,000 for performance).
284
+
285
+ Args:
286
+ filter_expr: Optional Dynamic LINQ filter expression.
287
+ substitutions: Substitution values for the filter.
288
+ order_by: Field to order by.
289
+ descending: Whether to return results in descending order.
290
+ take: Maximum number of items to fetch. Defaults to 10,000 to prevent
291
+ performance issues with very large datasets.
292
+
293
+ Returns:
294
+ List of product objects (up to take count).
295
+ """
296
+ url = f"{_get_testmonitor_base_url()}/query-products"
297
+ all_products: List[Dict[str, Any]] = []
298
+ continuation_token: Optional[str] = None
299
+ page_size = 100 # Fetch in larger batches for efficiency
300
+
301
+ while True:
302
+ # Calculate how many items to request in this batch
303
+ if take is not None:
304
+ remaining = take - len(all_products)
305
+ if remaining <= 0:
306
+ break
307
+ batch_size = min(page_size, remaining)
308
+ else:
309
+ batch_size = page_size
310
+
311
+ payload: Dict[str, Any] = {
312
+ "take": batch_size,
313
+ "descending": descending,
314
+ }
315
+
316
+ if order_by:
317
+ payload["orderBy"] = order_by
318
+ if filter_expr:
319
+ payload["filter"] = filter_expr
320
+ if substitutions:
321
+ payload["substitutions"] = substitutions
322
+ if continuation_token:
323
+ payload["continuationToken"] = continuation_token
324
+
325
+ resp = make_api_request("POST", url, payload=payload)
326
+ data = resp.json()
327
+
328
+ products = data.get("products", []) if isinstance(data, dict) else []
329
+ all_products.extend(products)
330
+
331
+ continuation_token = data.get("continuationToken") if isinstance(data, dict) else None
332
+ # Stop if no more pages or we've reached the limit
333
+ if not continuation_token:
334
+ break
335
+ if take is not None and len(all_products) >= take:
336
+ break
337
+
338
+ return all_products[:take] if take is not None else all_products
339
+
340
+
341
+ def _fetch_products_page(
342
+ filter_expr: Optional[str],
343
+ substitutions: List[Any],
344
+ order_by: Optional[str],
345
+ descending: bool,
346
+ take: int = 25,
347
+ continuation_token: Optional[str] = None,
348
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
349
+ """Fetch a single page of products.
350
+
351
+ Args:
352
+ filter_expr: Optional Dynamic LINQ filter expression.
353
+ substitutions: Substitution values for the filter.
354
+ order_by: Field to order by.
355
+ descending: Whether to return results in descending order.
356
+ take: Number of items to fetch.
357
+ continuation_token: Optional token to resume from a previous query.
358
+
359
+ Returns:
360
+ Tuple of (products list, next continuation token or None).
361
+ """
362
+ url = f"{_get_testmonitor_base_url()}/query-products"
363
+ payload: Dict[str, Any] = {
364
+ "take": take,
365
+ "descending": descending,
366
+ }
367
+
368
+ if order_by:
369
+ payload["orderBy"] = order_by
370
+ if filter_expr:
371
+ payload["filter"] = filter_expr
372
+ if substitutions:
373
+ payload["substitutions"] = substitutions
374
+ if continuation_token:
375
+ payload["continuationToken"] = continuation_token
376
+
377
+ resp = make_api_request("POST", url, payload=payload)
378
+ data = resp.json()
379
+
380
+ products = data.get("products", []) if isinstance(data, dict) else []
381
+ next_token = data.get("continuationToken") if isinstance(data, dict) else None
382
+
383
+ return products, next_token
384
+
385
+
386
+ def _fetch_results_page(
387
+ filter_expr: Optional[str],
388
+ substitutions: List[Any],
389
+ product_filter: Optional[str],
390
+ product_substitutions: List[Any],
391
+ order_by: Optional[str],
392
+ descending: bool,
393
+ take: int = 25,
394
+ continuation_token: Optional[str] = None,
395
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
396
+ """Fetch a single page of test results.
397
+
398
+ Args:
399
+ filter_expr: Optional Dynamic LINQ filter expression for results.
400
+ substitutions: Substitution values for the results filter.
401
+ product_filter: Optional Dynamic LINQ filter expression for products.
402
+ product_substitutions: Substitution values for the product filter.
403
+ order_by: Field to order by.
404
+ descending: Whether to return results in descending order.
405
+ take: Number of items to fetch.
406
+ continuation_token: Optional token to resume from a previous query.
407
+
408
+ Returns:
409
+ Tuple of (results list, next continuation token or None).
410
+ """
411
+ url = f"{_get_testmonitor_base_url()}/query-results"
412
+ payload: Dict[str, Any] = {
413
+ "take": take,
414
+ "descending": descending,
415
+ }
416
+
417
+ if order_by:
418
+ payload["orderBy"] = order_by
419
+ if filter_expr:
420
+ payload["filter"] = filter_expr
421
+ if substitutions:
422
+ payload["substitutions"] = substitutions
423
+ if product_filter:
424
+ payload["productFilter"] = product_filter
425
+ if product_substitutions:
426
+ payload["productSubstitutions"] = product_substitutions
427
+ if continuation_token:
428
+ payload["continuationToken"] = continuation_token
429
+
430
+ resp = make_api_request("POST", url, payload=payload)
431
+ data = resp.json()
432
+
433
+ results = data.get("results", []) if isinstance(data, dict) else []
434
+ next_token = data.get("continuationToken") if isinstance(data, dict) else None
435
+
436
+ return results, next_token
437
+
438
+
439
+ def _query_all_results(
440
+ filter_expr: Optional[str],
441
+ substitutions: List[Any],
442
+ product_filter: Optional[str],
443
+ product_substitutions: List[Any],
444
+ order_by: Optional[str],
445
+ descending: bool,
446
+ take: Optional[int] = 10000,
447
+ ) -> List[Dict[str, Any]]:
448
+ """Query test results using continuation token pagination.
449
+
450
+ Fetches up to take items (default 10,000 for performance).
451
+
452
+ Args:
453
+ filter_expr: Optional Dynamic LINQ filter expression for results.
454
+ substitutions: Substitution values for the results filter.
455
+ product_filter: Optional Dynamic LINQ filter expression for products.
456
+ product_substitutions: Substitution values for the product filter.
457
+ order_by: Field to order by.
458
+ descending: Whether to return results in descending order.
459
+ take: Maximum number of items to fetch. Defaults to 10,000 to prevent
460
+ performance issues with very large datasets.
461
+
462
+ Returns:
463
+ List of test result objects (up to take count).
464
+ """
465
+ url = f"{_get_testmonitor_base_url()}/query-results"
466
+ all_results: List[Dict[str, Any]] = []
467
+ continuation_token: Optional[str] = None
468
+ page_size = 1000 # Use API's maximum batch size for efficiency
469
+
470
+ while True:
471
+ # Calculate how many items to request in this batch
472
+ if take is not None:
473
+ remaining = take - len(all_results)
474
+ if remaining <= 0:
475
+ break
476
+ batch_size = min(page_size, remaining)
477
+ else:
478
+ batch_size = page_size
479
+
480
+ payload: Dict[str, Any] = {
481
+ "take": batch_size,
482
+ "descending": descending,
483
+ }
484
+
485
+ if order_by:
486
+ payload["orderBy"] = order_by
487
+ if filter_expr:
488
+ payload["filter"] = filter_expr
489
+ if substitutions:
490
+ payload["substitutions"] = substitutions
491
+ if product_filter:
492
+ payload["productFilter"] = product_filter
493
+ if product_substitutions:
494
+ payload["productSubstitutions"] = product_substitutions
495
+ if continuation_token:
496
+ payload["continuationToken"] = continuation_token
497
+
498
+ resp = make_api_request("POST", url, payload=payload)
499
+ data = resp.json()
500
+
501
+ results = data.get("results", []) if isinstance(data, dict) else []
502
+ all_results.extend(results)
503
+
504
+ continuation_token = data.get("continuationToken") if isinstance(data, dict) else None
505
+ # Stop if no more pages or we've reached the limit
506
+ if not continuation_token:
507
+ break
508
+ if take is not None and len(all_results) >= take:
509
+ break
510
+
511
+ return all_results[:take] if take is not None else all_results
512
+
513
+
514
+ def _query_counts_by_status(
515
+ filter_expr: Optional[str],
516
+ substitutions: List[Any],
517
+ product_filter: Optional[str],
518
+ product_substitutions: List[Any],
519
+ group_by: Optional[str] = None,
520
+ ) -> Dict[str, Any]:
521
+ """Query result counts by status using efficient count-only queries.
522
+
523
+ Instead of fetching all results and aggregating client-side, this makes
524
+ separate queries with returnCount=true and take=0 to get counts without
525
+ data transfer.
526
+
527
+ Args:
528
+ filter_expr: Optional Dynamic LINQ filter expression for results.
529
+ substitutions: Substitution values for the results filter.
530
+ product_filter: Optional Dynamic LINQ filter expression for products.
531
+ product_substitutions: Substitution values for the product filter.
532
+ group_by: Field to group by. None or "status" for status grouping (optimized).
533
+ Other fields fall back to client-side aggregation.
534
+
535
+ Returns:
536
+ Dictionary with counts, e.g., {"total": 125, "groups": {"PASSED": 120, "FAILED": 5}}
537
+ """
538
+ # Only status grouping (None or explicit "status") is optimized
539
+ # Fall back to client-side for other fields
540
+ if group_by and group_by != "status":
541
+ # Fall back to fetching all results for non-status grouping
542
+ # Default max is 10,000 to prevent performance issues
543
+ max_items = 10000
544
+ results = _query_all_results(
545
+ filter_expr,
546
+ substitutions,
547
+ product_filter,
548
+ product_substitutions,
549
+ None,
550
+ False,
551
+ take=max_items,
552
+ )
553
+ return _summarize_results(results, group_by, max_items=max_items)
554
+
555
+ # All possible status types from the API
556
+ # Note: API uses "TIMEDOUT" (no underscore), not "TIMED_OUT" as documented
557
+ status_types = [
558
+ "PASSED",
559
+ "FAILED",
560
+ "RUNNING",
561
+ "WAITING",
562
+ "TERMINATED",
563
+ "ERRORED",
564
+ "DONE",
565
+ "LOOPING",
566
+ "SKIPPED",
567
+ "TIMEDOUT",
568
+ "CUSTOM",
569
+ ]
570
+
571
+ url = f"{_get_testmonitor_base_url()}/query-results"
572
+ counts: Dict[str, int] = {}
573
+
574
+ # Make a query for each status type to get counts
575
+ for status in status_types:
576
+ # Build filter combining base filter with status filter
577
+ # Use the same substitution approach as the working --status flag code
578
+ combined_filter_parts = []
579
+ combined_subs = list(substitutions) if substitutions else []
580
+
581
+ if filter_expr:
582
+ combined_filter_parts.append(filter_expr)
583
+
584
+ # Add status filter with substitution (same pattern as list_results --status flag)
585
+ status_index = len(combined_subs)
586
+ combined_filter_parts.append(f"status.statusType == @{status_index}")
587
+ combined_subs.append(status)
588
+
589
+ combined_filter = " && ".join(combined_filter_parts)
590
+
591
+ payload: Dict[str, Any] = {
592
+ "filter": combined_filter,
593
+ "substitutions": combined_subs,
594
+ "take": 0, # Don't fetch any data
595
+ "returnCount": True, # Just get the count
596
+ "descending": True, # Match request shape used by other query-results calls
597
+ }
598
+
599
+ if product_filter:
600
+ payload["productFilter"] = product_filter
601
+ if product_substitutions:
602
+ payload["productSubstitutions"] = product_substitutions
603
+
604
+ try:
605
+ resp = make_api_request("POST", url, payload=payload)
606
+ result_data = resp.json()
607
+ count = result_data.get("totalCount", 0)
608
+ if count > 0:
609
+ counts[status] = count
610
+ except Exception as exc:
611
+ handle_api_error(exc)
612
+
613
+ # Return in same format as _summarize_results
614
+ total = sum(counts.values())
615
+ result = {"total": total, "groups": counts}
616
+ return result
617
+
618
+
619
+ def _resolve_group_field(group_by: Optional[str]) -> Optional[str]:
620
+ """Resolve user-facing group-by value to internal field name.
621
+
622
+ Args:
623
+ group_by: User-facing group-by value (e.g., "status", "programName").
624
+
625
+ Returns:
626
+ Internal field name to use for grouping, or None for default status grouping.
627
+ """
628
+ if not group_by:
629
+ return None
630
+
631
+ group_key = group_by.lower()
632
+ group_field_map = {
633
+ "status": None, # Use default status grouping in _summarize_results
634
+ "programname": "programName",
635
+ "serialnumber": "serialNumber",
636
+ "operator": "operator",
637
+ "hostname": "hostName",
638
+ "systemid": "systemId",
639
+ }
640
+ return group_field_map.get(group_key, group_by)
641
+
642
+
643
+ def _summarize_results(
644
+ results: List[Dict[str, Any]],
645
+ group_by_field: Optional[str] = None,
646
+ max_items: Optional[int] = None,
647
+ ) -> Dict[str, Any]:
648
+ """Summarize test results by aggregating status and other metrics.
649
+
650
+ Args:
651
+ results: List of result objects.
652
+ group_by_field: Field to group by (status, programName, etc.)
653
+ max_items: Maximum items that were fetched. If provided and equals len(results),
654
+ adds a truncation indicator.
655
+
656
+ Returns:
657
+ Dictionary with summary statistics.
658
+ """
659
+ if not results:
660
+ return {"total": 0, "groups": {}}
661
+
662
+ summary: Dict[str, Any] = {
663
+ "total": len(results),
664
+ "groups": {},
665
+ }
666
+
667
+ # Add truncation indicator if results hit the max limit
668
+ if max_items is not None and len(results) >= max_items:
669
+ summary["truncated"] = True
670
+ summary["note"] = f"Results limited to {max_items} items"
671
+
672
+ # Group results
673
+ groups: Dict[str, List[Dict[str, Any]]] = {}
674
+ if group_by_field:
675
+ for result in results:
676
+ # Special case for status field - extract statusType
677
+ if group_by_field == "status":
678
+ status_value = result.get("status", {})
679
+ if isinstance(status_value, dict):
680
+ key = str(status_value.get("statusType", "N/A"))
681
+ else:
682
+ key = str(status_value)
683
+ else:
684
+ key = str(result.get(group_by_field, "N/A"))
685
+ if key not in groups:
686
+ groups[key] = []
687
+ groups[key].append(result)
688
+ else:
689
+ # Default grouping by status
690
+ for result in results:
691
+ status_value = result.get("status", {})
692
+ if isinstance(status_value, dict):
693
+ status_type = str(status_value.get("statusType", "N/A"))
694
+ else:
695
+ status_type = "N/A" if status_value is None else str(status_value)
696
+ if status_type not in groups:
697
+ groups[status_type] = []
698
+ groups[status_type].append(result)
699
+
700
+ # Calculate statistics per group - store as integers, not dicts
701
+ for group_key, group_results in groups.items():
702
+ summary["groups"][group_key] = len(group_results)
703
+
704
+ return summary
705
+
706
+
707
+ def _summarize_products(
708
+ products: List[Dict[str, Any]], max_items: Optional[int] = None
709
+ ) -> Dict[str, Any]:
710
+ """Summarize product data.
711
+
712
+ Args:
713
+ products: List of product objects.
714
+ max_items: Maximum items that were fetched. If provided and equals len(products),
715
+ adds a truncation indicator.
716
+
717
+ Returns:
718
+ Dictionary with summary statistics.
719
+ """
720
+ summary: Dict[str, Any] = {
721
+ "total": len(products),
722
+ "families": len(set(p.get("family") or "N/A" for p in products)),
723
+ }
724
+
725
+ # Add truncation indicator if results hit the max limit
726
+ if max_items is not None and len(products) >= max_items:
727
+ summary["truncated"] = True
728
+ summary["note"] = f"Results limited to {max_items} items"
729
+
730
+ return summary
731
+
732
+
733
+ def register_testmonitor_commands(cli: Any) -> None:
734
+ """Register the 'testmonitor' command group and its subcommands."""
735
+
736
+ @cli.group()
737
+ def testmonitor() -> None:
738
+ """Commands for test monitor products and results."""
739
+
740
+ @testmonitor.group()
741
+ def product() -> None:
742
+ """Manage test monitor products."""
743
+
744
+ @testmonitor.group()
745
+ def result() -> None:
746
+ """Manage test monitor test results."""
747
+
748
+ @product.command(name="list")
749
+ @click.option(
750
+ "--format",
751
+ "-f",
752
+ type=click.Choice(["table", "json"]),
753
+ default="table",
754
+ show_default=True,
755
+ help="Output format",
756
+ )
757
+ @click.option(
758
+ "--take",
759
+ "-t",
760
+ type=int,
761
+ default=25,
762
+ show_default=True,
763
+ help="Items per page (table output only)",
764
+ )
765
+ @click.option("--name", help="Filter by product name (contains)")
766
+ @click.option("--part-number", help="Filter by product part number (contains)")
767
+ @click.option("--family", help="Filter by product family (contains)")
768
+ @click.option("--workspace", "-w", help="Filter by workspace name or ID")
769
+ @click.option(
770
+ "--filter",
771
+ "filter_query",
772
+ help="Dynamic LINQ filter expression for products",
773
+ )
774
+ @click.option(
775
+ "--substitution",
776
+ "substitutions",
777
+ multiple=True,
778
+ help="Substitution value for --filter (repeatable)",
779
+ )
780
+ @click.option(
781
+ "--order-by",
782
+ type=click.Choice(
783
+ ["ID", "PART_NUMBER", "NAME", "FAMILY", "UPDATED_AT"], case_sensitive=False
784
+ ),
785
+ help="Order by field",
786
+ )
787
+ @click.option(
788
+ "--descending/--ascending",
789
+ default=True,
790
+ help="Sort order (default: descending)",
791
+ )
792
+ @click.option(
793
+ "--summary",
794
+ is_flag=True,
795
+ help="Show summary statistics (total count and number of families)",
796
+ )
797
+ def list_products(
798
+ format: str,
799
+ take: int,
800
+ name: Optional[str],
801
+ part_number: Optional[str],
802
+ family: Optional[str],
803
+ workspace: Optional[str],
804
+ filter_query: Optional[str],
805
+ substitutions: Tuple[str, ...],
806
+ order_by: Optional[str],
807
+ descending: bool,
808
+ summary: bool,
809
+ ) -> None:
810
+ """List products in Test Monitor."""
811
+ format_output = validate_output_format(format)
812
+
813
+ try:
814
+ # Fetch workspace map once for both filter resolution and display
815
+ try:
816
+ workspace_map = get_workspace_map()
817
+ except Exception:
818
+ workspace_map = {}
819
+
820
+ filter_parts: List[str] = []
821
+ filter_substitutions: List[Any] = []
822
+
823
+ _append_filter(filter_parts, filter_substitutions, "name.Contains(@{index})", name)
824
+ _append_filter(
825
+ filter_parts, filter_substitutions, "partNumber.Contains(@{index})", part_number
826
+ )
827
+ _append_filter(filter_parts, filter_substitutions, "family.Contains(@{index})", family)
828
+
829
+ workspace = get_effective_workspace(workspace)
830
+ if workspace:
831
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
832
+ _append_filter(
833
+ filter_parts, filter_substitutions, "workspace == @{index}", workspace_id
834
+ )
835
+
836
+ base_filter = " && ".join(filter_parts) if filter_parts else None
837
+ user_subs = _parse_substitutions(substitutions)
838
+
839
+ filter_expr, merged_subs = _combine_filter_parts(
840
+ base_filter, filter_substitutions, filter_query, user_subs
841
+ )
842
+
843
+ if order_by:
844
+ order_by = order_by.upper()
845
+
846
+ def product_formatter(item: Dict[str, Any]) -> List[str]:
847
+ ws_id = item.get("workspace", "")
848
+ ws_name = get_workspace_display_name(ws_id, workspace_map)
849
+ return [
850
+ item.get("name", ""),
851
+ item.get("partNumber", ""),
852
+ item.get("family", ""),
853
+ _format_date(item.get("updatedAt", "")),
854
+ ws_name,
855
+ item.get("id", ""),
856
+ ]
857
+
858
+ # If JSON output, fetch all pages
859
+ if format_output.lower() == "json":
860
+ # Check total count first to warn about large datasets
861
+ _warn_if_large_dataset(
862
+ endpoint="query-products",
863
+ filter_expr=filter_expr,
864
+ substitutions=merged_subs,
865
+ product_filter=None,
866
+ product_substitutions=[],
867
+ order_by=order_by,
868
+ descending=descending,
869
+ )
870
+ products = _query_all_products(filter_expr, merged_subs, order_by, descending)
871
+
872
+ # Handle --summary flag for JSON output
873
+ if summary:
874
+ summary_stats = _summarize_products(products, max_items=10000)
875
+ click.echo(json.dumps(summary_stats, indent=2))
876
+ else:
877
+ mock_resp: Any = FilteredResponse({"products": products})
878
+ UniversalResponseHandler.handle_list_response(
879
+ resp=mock_resp,
880
+ data_key="products",
881
+ item_name="product",
882
+ format_output=format_output,
883
+ formatter_func=product_formatter,
884
+ headers=["Name", "Part Number", "Family", "Updated", "Workspace", "ID"],
885
+ column_widths=[30, 18, 16, 12, 20, 36],
886
+ empty_message="No products found.",
887
+ enable_pagination=False,
888
+ page_size=take,
889
+ )
890
+ else:
891
+ # Interactive pagination for table output
892
+ def fetch_page(
893
+ cont: Optional[str] = None,
894
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
895
+ return _fetch_products_page(
896
+ filter_expr, merged_subs, order_by, descending, take, cont
897
+ )
898
+
899
+ # For table output with summary, collect all data first
900
+ if summary:
901
+ # Check total count first to warn about large datasets
902
+ _warn_if_large_dataset(
903
+ endpoint="query-products",
904
+ filter_expr=filter_expr,
905
+ substitutions=merged_subs,
906
+ product_filter=None,
907
+ product_substitutions=[],
908
+ order_by=order_by,
909
+ descending=descending,
910
+ )
911
+ all_products = _query_all_products(
912
+ filter_expr, merged_subs, order_by, descending
913
+ )
914
+ summary_stats = _summarize_products(all_products, max_items=10000)
915
+ click.echo("\nProduct Summary Statistics:")
916
+ click.echo(f" Total Products: {summary_stats['total']}")
917
+ click.echo(f" Families: {summary_stats['families']}")
918
+ if summary_stats.get("truncated"):
919
+ click.echo(f" Note: {summary_stats['note']}", err=True)
920
+ click.echo()
921
+ else:
922
+ _handle_interactive_pagination(
923
+ fetch_page_func=fetch_page,
924
+ data_key="products",
925
+ item_name="product",
926
+ format_output=format_output,
927
+ formatter_func=product_formatter,
928
+ headers=["Name", "Part Number", "Family", "Updated", "Workspace", "ID"],
929
+ column_widths=[30, 18, 16, 12, 20, 36],
930
+ empty_message="No products found.",
931
+ take=take,
932
+ )
933
+ except Exception as exc: # noqa: BLE001
934
+ handle_api_error(exc)
935
+
936
+ @result.command(name="list")
937
+ @click.option(
938
+ "--format",
939
+ "-f",
940
+ type=click.Choice(["table", "json"]),
941
+ default="table",
942
+ show_default=True,
943
+ help="Output format",
944
+ )
945
+ @click.option(
946
+ "--take",
947
+ "-t",
948
+ type=int,
949
+ default=25,
950
+ show_default=True,
951
+ help="Items per page (table output only)",
952
+ )
953
+ @click.option("--status", help="Filter by status type (e.g., PASSED, FAILED)")
954
+ @click.option("--program-name", help="Filter by program name (contains)")
955
+ @click.option("--serial-number", help="Filter by serial number (contains)")
956
+ @click.option("--part-number", help="Filter by part number (contains)")
957
+ @click.option("--operator", help="Filter by operator name (contains)")
958
+ @click.option("--host-name", help="Filter by host name (contains)")
959
+ @click.option("--system-id", help="Filter by system ID")
960
+ @click.option("--workspace", "-w", help="Filter by workspace name or ID")
961
+ @click.option(
962
+ "--filter",
963
+ "filter_query",
964
+ help="Dynamic LINQ filter expression for results",
965
+ )
966
+ @click.option(
967
+ "--substitution",
968
+ "substitutions",
969
+ multiple=True,
970
+ help="Substitution value for --filter (repeatable)",
971
+ )
972
+ @click.option(
973
+ "--product-filter",
974
+ help="Dynamic LINQ filter expression for associated products",
975
+ )
976
+ @click.option(
977
+ "--product-substitution",
978
+ "product_substitutions",
979
+ multiple=True,
980
+ help="Substitution value for --product-filter (repeatable)",
981
+ )
982
+ @click.option(
983
+ "--order-by",
984
+ type=click.Choice(
985
+ [
986
+ "ID",
987
+ "STARTED_AT",
988
+ "UPDATED_AT",
989
+ "PROGRAM_NAME",
990
+ "SYSTEM_ID",
991
+ "HOST_NAME",
992
+ "OPERATOR",
993
+ "SERIAL_NUMBER",
994
+ "PART_NUMBER",
995
+ "PROPERTIES",
996
+ "TOTAL_TIME_IN_SECONDS",
997
+ ],
998
+ case_sensitive=False,
999
+ ),
1000
+ help="Order by field",
1001
+ )
1002
+ @click.option(
1003
+ "--descending/--ascending",
1004
+ default=True,
1005
+ help="Sort order (default: descending)",
1006
+ )
1007
+ @click.option(
1008
+ "--summary",
1009
+ is_flag=True,
1010
+ help="Show summary statistics grouped by status or specified field",
1011
+ )
1012
+ @click.option(
1013
+ "--group-by",
1014
+ type=click.Choice(
1015
+ ["status", "programName", "serialNumber", "operator", "hostName", "systemId"],
1016
+ case_sensitive=False,
1017
+ ),
1018
+ help="Group summary by field (implies --summary)",
1019
+ )
1020
+ def list_results(
1021
+ format: str,
1022
+ take: int,
1023
+ status: Optional[str],
1024
+ program_name: Optional[str],
1025
+ serial_number: Optional[str],
1026
+ part_number: Optional[str],
1027
+ operator: Optional[str],
1028
+ host_name: Optional[str],
1029
+ system_id: Optional[str],
1030
+ workspace: Optional[str],
1031
+ filter_query: Optional[str],
1032
+ substitutions: Tuple[str, ...],
1033
+ product_filter: Optional[str],
1034
+ product_substitutions: Tuple[str, ...],
1035
+ order_by: Optional[str],
1036
+ descending: bool,
1037
+ summary: bool,
1038
+ group_by: Optional[str],
1039
+ ) -> None:
1040
+ """List test results in Test Monitor."""
1041
+ format_output = validate_output_format(format)
1042
+
1043
+ try:
1044
+ filter_parts: List[str] = []
1045
+ filter_substitutions: List[Any] = []
1046
+
1047
+ if status:
1048
+ normalized_status = status.upper().replace("-", "_")
1049
+ _append_filter(
1050
+ filter_parts,
1051
+ filter_substitutions,
1052
+ "status.statusType == @{index}",
1053
+ normalized_status,
1054
+ )
1055
+
1056
+ _append_filter(
1057
+ filter_parts,
1058
+ filter_substitutions,
1059
+ "programName.Contains(@{index})",
1060
+ program_name,
1061
+ )
1062
+ _append_filter(
1063
+ filter_parts,
1064
+ filter_substitutions,
1065
+ "serialNumber.Contains(@{index})",
1066
+ serial_number,
1067
+ )
1068
+ _append_filter(
1069
+ filter_parts,
1070
+ filter_substitutions,
1071
+ "partNumber.Contains(@{index})",
1072
+ part_number,
1073
+ )
1074
+ _append_filter(
1075
+ filter_parts, filter_substitutions, "operator.Contains(@{index})", operator
1076
+ )
1077
+ _append_filter(
1078
+ filter_parts, filter_substitutions, "hostName.Contains(@{index})", host_name
1079
+ )
1080
+ _append_filter(filter_parts, filter_substitutions, "systemId == @{index}", system_id)
1081
+
1082
+ workspace = get_effective_workspace(workspace)
1083
+ if workspace:
1084
+ workspace_map = get_workspace_map()
1085
+ workspace_id = resolve_workspace_filter(workspace, workspace_map)
1086
+ _append_filter(
1087
+ filter_parts, filter_substitutions, "workspace == @{index}", workspace_id
1088
+ )
1089
+
1090
+ base_filter = " && ".join(filter_parts) if filter_parts else None
1091
+ user_subs = _parse_substitutions(substitutions)
1092
+
1093
+ filter_expr, merged_subs = _combine_filter_parts(
1094
+ base_filter, filter_substitutions, filter_query, user_subs
1095
+ )
1096
+
1097
+ product_filter_expr: Optional[str] = None
1098
+ product_subs: List[Any] = []
1099
+
1100
+ if product_filter:
1101
+ product_subs = _parse_substitutions(product_substitutions)
1102
+ product_filter_expr = product_filter
1103
+
1104
+ if order_by:
1105
+ order_by = order_by.upper()
1106
+
1107
+ def result_formatter(item: Dict[str, Any]) -> List[str]:
1108
+ status_obj = item.get("status", {}) if isinstance(item, dict) else {}
1109
+ status_value = status_obj.get("statusType") or status_obj.get("statusName", "")
1110
+ return [
1111
+ status_value,
1112
+ item.get("programName", ""),
1113
+ item.get("partNumber", ""),
1114
+ item.get("serialNumber", ""),
1115
+ _format_date(item.get("startedAt", "")),
1116
+ _format_duration(item.get("totalTimeInSeconds")),
1117
+ item.get("id", ""),
1118
+ ]
1119
+
1120
+ # If JSON output, fetch all pages
1121
+ if format_output.lower() == "json":
1122
+ # Handle --summary flag for JSON output using efficient count queries
1123
+ if summary or group_by:
1124
+ group_field = _resolve_group_field(group_by)
1125
+ # Use efficient count-only queries for status grouping
1126
+ summary_stats = _query_counts_by_status(
1127
+ filter_expr,
1128
+ merged_subs,
1129
+ product_filter_expr,
1130
+ product_subs,
1131
+ group_field,
1132
+ )
1133
+ click.echo(json.dumps(summary_stats, indent=2))
1134
+ else:
1135
+ # Check total count first to warn about large datasets
1136
+ _warn_if_large_dataset(
1137
+ endpoint="query-results",
1138
+ filter_expr=filter_expr,
1139
+ substitutions=merged_subs,
1140
+ product_filter=product_filter_expr,
1141
+ product_substitutions=product_subs,
1142
+ order_by=order_by,
1143
+ descending=descending,
1144
+ )
1145
+ results = _query_all_results(
1146
+ filter_expr,
1147
+ merged_subs,
1148
+ product_filter_expr,
1149
+ product_subs,
1150
+ order_by,
1151
+ descending,
1152
+ take=take,
1153
+ )
1154
+
1155
+ mock_resp: Any = FilteredResponse({"results": results})
1156
+ UniversalResponseHandler.handle_list_response(
1157
+ resp=mock_resp,
1158
+ data_key="results",
1159
+ item_name="result",
1160
+ format_output=format_output,
1161
+ formatter_func=result_formatter,
1162
+ headers=[
1163
+ "Status",
1164
+ "Program",
1165
+ "Part Number",
1166
+ "Serial",
1167
+ "Started",
1168
+ "Duration(s)",
1169
+ "ID",
1170
+ ],
1171
+ column_widths=[12, 30, 16, 16, 12, 12, 36],
1172
+ empty_message="No test results found.",
1173
+ enable_pagination=False,
1174
+ page_size=take,
1175
+ )
1176
+ else:
1177
+ # Interactive pagination for table output
1178
+ def fetch_page(
1179
+ cont: Optional[str] = None,
1180
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
1181
+ return _fetch_results_page(
1182
+ filter_expr,
1183
+ merged_subs,
1184
+ product_filter_expr,
1185
+ product_subs,
1186
+ order_by,
1187
+ descending,
1188
+ take,
1189
+ cont,
1190
+ )
1191
+
1192
+ # For table output with summary, collect all data first
1193
+ if summary or group_by:
1194
+ # Check total count first to warn about large datasets
1195
+ _warn_if_large_dataset(
1196
+ endpoint="query-results",
1197
+ filter_expr=filter_expr,
1198
+ substitutions=merged_subs,
1199
+ product_filter=product_filter_expr,
1200
+ product_substitutions=product_subs,
1201
+ order_by=order_by,
1202
+ descending=descending,
1203
+ )
1204
+ all_results = _query_all_results(
1205
+ filter_expr,
1206
+ merged_subs,
1207
+ product_filter_expr,
1208
+ product_subs,
1209
+ order_by,
1210
+ descending,
1211
+ )
1212
+ group_field = _resolve_group_field(group_by)
1213
+ summary_stats = _summarize_results(all_results, group_field, max_items=10000)
1214
+
1215
+ # Display appropriate label based on grouping
1216
+ group_key = group_by.lower() if group_by else "status"
1217
+ group_label = group_key if group_key != "status" else "statusType"
1218
+ click.echo(f"\nTest Results Summary (grouped by {group_label}):")
1219
+ click.echo(f" Total Results: {summary_stats['total']}")
1220
+ if "groups" in summary_stats:
1221
+ for group_name, count in summary_stats["groups"].items():
1222
+ click.echo(f" {group_name}: {count}")
1223
+ if summary_stats.get("truncated"):
1224
+ click.echo(f" Note: {summary_stats['note']}", err=True)
1225
+ click.echo()
1226
+ else:
1227
+ _handle_interactive_pagination(
1228
+ fetch_page_func=fetch_page,
1229
+ data_key="results",
1230
+ item_name="result",
1231
+ format_output=format_output,
1232
+ formatter_func=result_formatter,
1233
+ headers=[
1234
+ "Status",
1235
+ "Program",
1236
+ "Part Number",
1237
+ "Serial",
1238
+ "Started",
1239
+ "Duration(s)",
1240
+ "ID",
1241
+ ],
1242
+ column_widths=[12, 30, 16, 16, 12, 12, 36],
1243
+ empty_message="No test results found.",
1244
+ take=take,
1245
+ )
1246
+ except Exception as exc: # noqa: BLE001
1247
+ handle_api_error(exc)
1248
+
1249
+ @product.command(name="get")
1250
+ @click.argument("product_id")
1251
+ @click.option("--format", "-f", type=click.Choice(["table", "json"]), default="table")
1252
+ def get_product(product_id: str, format: str) -> None:
1253
+ """Get detailed information about a specific product.
1254
+
1255
+ Args:
1256
+ product_id: The ID of the product to retrieve.
1257
+ format: Output format (table or json).
1258
+ """
1259
+ try:
1260
+ validate_output_format(format)
1261
+ url = f"{_get_testmonitor_base_url()}/products/{product_id}"
1262
+ resp = make_api_request("GET", url)
1263
+ resp.raise_for_status()
1264
+
1265
+ product = resp.json()
1266
+
1267
+ if format == "json":
1268
+ click.echo(json.dumps(product, indent=2))
1269
+ else:
1270
+ # Table format
1271
+ click.echo(f"\nProduct: {product.get('name', 'N/A')} ({product.get('id', 'N/A')})")
1272
+ click.echo(f"Part Number: {product.get('partNumber', 'N/A')}")
1273
+ click.echo(f"Family: {product.get('family', 'N/A')}")
1274
+ workspace = product.get("workspace", "N/A")
1275
+ if workspace != "N/A":
1276
+ workspace_name = get_workspace_display_name(workspace)
1277
+ click.echo(f"Workspace: {workspace_name} ({workspace})")
1278
+ click.echo(f"Updated: {_format_date(product.get('updatedAt', 'N/A'))}")
1279
+
1280
+ # Display keywords and properties if present
1281
+ if product.get("keywords"):
1282
+ click.echo(f"Keywords: {', '.join(product['keywords'])}")
1283
+ if product.get("properties"):
1284
+ click.echo("Properties:")
1285
+ for key, value in product["properties"].items():
1286
+ click.echo(f" {key}: {value}")
1287
+ click.echo()
1288
+ except Exception as exc: # noqa: BLE001
1289
+ handle_api_error(exc)
1290
+
1291
+ @product.command(name="create")
1292
+ @click.option("--part-number", required=True, help="Part number of the product")
1293
+ @click.option("--name", default=None, help="Name of the product")
1294
+ @click.option("--family", default=None, help="Product family")
1295
+ @click.option("--workspace", "-w", default=None, help="Workspace name or ID")
1296
+ @click.option(
1297
+ "--keyword",
1298
+ "keywords",
1299
+ multiple=True,
1300
+ help="Keyword to associate with the product (repeatable)",
1301
+ )
1302
+ @click.option(
1303
+ "--property",
1304
+ "properties",
1305
+ multiple=True,
1306
+ metavar="KEY=VALUE",
1307
+ help="Key-value property (repeatable, format: key=value)",
1308
+ )
1309
+ @click.option(
1310
+ "--format",
1311
+ "-f",
1312
+ type=click.Choice(["table", "json"]),
1313
+ default="table",
1314
+ show_default=True,
1315
+ help="Output format",
1316
+ )
1317
+ def create_product(
1318
+ part_number: str,
1319
+ name: Optional[str],
1320
+ family: Optional[str],
1321
+ workspace: Optional[str],
1322
+ keywords: Tuple[str, ...],
1323
+ properties: Tuple[str, ...],
1324
+ format: str,
1325
+ ) -> None:
1326
+ """Create a new product in Test Monitor."""
1327
+ from .utils import check_readonly_mode
1328
+
1329
+ check_readonly_mode("create a product")
1330
+
1331
+ try:
1332
+ product_obj: Dict[str, Any] = {"partNumber": part_number}
1333
+
1334
+ if name is not None:
1335
+ product_obj["name"] = name
1336
+ if family is not None:
1337
+ product_obj["family"] = family
1338
+ if keywords:
1339
+ product_obj["keywords"] = list(keywords)
1340
+ if properties:
1341
+ props: Dict[str, str] = {}
1342
+ for prop in properties:
1343
+ if "=" not in prop:
1344
+ click.echo(
1345
+ f"✗ Invalid property format '{prop}': expected KEY=VALUE", err=True
1346
+ )
1347
+ sys.exit(ExitCodes.INVALID_INPUT)
1348
+ k, _, v = prop.partition("=")
1349
+ props[k.strip()] = v.strip()
1350
+ product_obj["properties"] = props
1351
+ if workspace is None:
1352
+ workspace = get_effective_workspace(workspace)
1353
+ if workspace is not None:
1354
+ try:
1355
+ workspace_map = get_workspace_map()
1356
+ except Exception:
1357
+ workspace_map = {}
1358
+ ws_id = resolve_workspace_filter(workspace, workspace_map)
1359
+ product_obj["workspace"] = ws_id
1360
+
1361
+ url = f"{_get_testmonitor_base_url()}/products"
1362
+ payload: Dict[str, Any] = {"products": [product_obj]}
1363
+ resp = make_api_request("POST", url, payload=payload)
1364
+ resp.raise_for_status()
1365
+ data = resp.json()
1366
+
1367
+ products = data.get("products", []) if isinstance(data, dict) else []
1368
+ failed = data.get("failed", []) if isinstance(data, dict) else []
1369
+
1370
+ if failed:
1371
+ click.echo(
1372
+ f"✗ Product creation partially failed: {len(failed)} item(s) failed.",
1373
+ err=True,
1374
+ )
1375
+ sys.exit(ExitCodes.GENERAL_ERROR)
1376
+
1377
+ if products:
1378
+ created = products[0]
1379
+ if format == "json":
1380
+ click.echo(json.dumps(created, indent=2))
1381
+ else:
1382
+ format_success(
1383
+ "Product created",
1384
+ {
1385
+ "id": created.get("id", ""),
1386
+ "name": created.get("name", ""),
1387
+ "partNumber": created.get("partNumber", ""),
1388
+ },
1389
+ )
1390
+ else:
1391
+ click.echo("✗ Product creation failed: no product returned.", err=True)
1392
+ sys.exit(ExitCodes.GENERAL_ERROR)
1393
+
1394
+ except SystemExit:
1395
+ raise
1396
+ except Exception as exc: # noqa: BLE001
1397
+ handle_api_error(exc)
1398
+
1399
+ @product.command(name="update")
1400
+ @click.argument("product_id")
1401
+ @click.option("--name", default=None, help="New name for the product")
1402
+ @click.option("--family", default=None, help="New product family")
1403
+ @click.option("--workspace", "-w", default=None, help="New workspace name or ID")
1404
+ @click.option(
1405
+ "--keyword",
1406
+ "keywords",
1407
+ multiple=True,
1408
+ help="Keyword to set on the product (repeatable; replaces all if --replace)",
1409
+ )
1410
+ @click.option(
1411
+ "--property",
1412
+ "properties",
1413
+ multiple=True,
1414
+ metavar="KEY=VALUE",
1415
+ help="Key-value property (repeatable, format: key=value; replaces all if --replace)",
1416
+ )
1417
+ @click.option(
1418
+ "--replace",
1419
+ is_flag=True,
1420
+ default=False,
1421
+ help="Replace existing fields instead of merging them",
1422
+ )
1423
+ @click.option(
1424
+ "--format",
1425
+ "-f",
1426
+ type=click.Choice(["table", "json"]),
1427
+ default="table",
1428
+ show_default=True,
1429
+ help="Output format",
1430
+ )
1431
+ def update_product(
1432
+ product_id: str,
1433
+ name: Optional[str],
1434
+ family: Optional[str],
1435
+ workspace: Optional[str],
1436
+ keywords: Tuple[str, ...],
1437
+ properties: Tuple[str, ...],
1438
+ replace: bool,
1439
+ format: str,
1440
+ ) -> None:
1441
+ """Update an existing product in Test Monitor.
1442
+
1443
+ Args:
1444
+ product_id: The ID of the product to update.
1445
+ name: New name for the product.
1446
+ family: New product family.
1447
+ workspace: New workspace name or ID.
1448
+ keywords: Keywords to set on the product.
1449
+ properties: Key-value properties in KEY=VALUE format.
1450
+ replace: Replace existing fields instead of merging.
1451
+ format: Output format (table or json).
1452
+ """
1453
+ from .utils import check_readonly_mode
1454
+
1455
+ check_readonly_mode("update a product")
1456
+
1457
+ try:
1458
+ product_obj: Dict[str, Any] = {"id": product_id}
1459
+
1460
+ if name is not None:
1461
+ product_obj["name"] = name
1462
+ if family is not None:
1463
+ product_obj["family"] = family
1464
+ if keywords:
1465
+ product_obj["keywords"] = list(keywords)
1466
+ if properties:
1467
+ props: Dict[str, str] = {}
1468
+ for prop in properties:
1469
+ if "=" not in prop:
1470
+ click.echo(
1471
+ f"✗ Invalid property format '{prop}': expected KEY=VALUE", err=True
1472
+ )
1473
+ sys.exit(ExitCodes.INVALID_INPUT)
1474
+ k, _, v = prop.partition("=")
1475
+ props[k.strip()] = v.strip()
1476
+ product_obj["properties"] = props
1477
+ if workspace is not None:
1478
+ try:
1479
+ workspace_map = get_workspace_map()
1480
+ except Exception:
1481
+ workspace_map = {}
1482
+ ws_id = resolve_workspace_filter(workspace, workspace_map)
1483
+ product_obj["workspace"] = ws_id
1484
+
1485
+ url = f"{_get_testmonitor_base_url()}/update-products"
1486
+ payload: Dict[str, Any] = {"products": [product_obj], "replace": replace}
1487
+ resp = make_api_request("POST", url, payload=payload)
1488
+ resp.raise_for_status()
1489
+ data = resp.json()
1490
+
1491
+ products = data.get("products", []) if isinstance(data, dict) else []
1492
+ failed = data.get("failed", []) if isinstance(data, dict) else []
1493
+
1494
+ if failed:
1495
+ click.echo(
1496
+ f"✗ Product update partially failed: {len(failed)} item(s) failed.",
1497
+ err=True,
1498
+ )
1499
+ sys.exit(ExitCodes.GENERAL_ERROR)
1500
+
1501
+ if products:
1502
+ updated = products[0]
1503
+ if format == "json":
1504
+ click.echo(json.dumps(updated, indent=2))
1505
+ else:
1506
+ format_success(
1507
+ "Product updated",
1508
+ {
1509
+ "id": updated.get("id", ""),
1510
+ "name": updated.get("name", ""),
1511
+ "partNumber": updated.get("partNumber", ""),
1512
+ },
1513
+ )
1514
+ else:
1515
+ click.echo("✗ Product update failed: no product returned.", err=True)
1516
+ sys.exit(ExitCodes.GENERAL_ERROR)
1517
+
1518
+ except SystemExit:
1519
+ raise
1520
+ except Exception as exc: # noqa: BLE001
1521
+ handle_api_error(exc)
1522
+
1523
+ @product.command(name="delete")
1524
+ @click.argument("product_ids", nargs=-1, required=True)
1525
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
1526
+ def delete_products(
1527
+ product_ids: Tuple[str, ...],
1528
+ yes: bool,
1529
+ ) -> None:
1530
+ """Delete one or more products by ID."""
1531
+ from .utils import check_readonly_mode
1532
+
1533
+ check_readonly_mode("delete products")
1534
+
1535
+ ids_list = list(product_ids)
1536
+ if not yes:
1537
+ id_str = ", ".join(ids_list)
1538
+ if not questionary.confirm(
1539
+ f"Delete product(s) {id_str}?",
1540
+ default=False,
1541
+ ).ask():
1542
+ click.echo("Aborted.")
1543
+ return
1544
+
1545
+ try:
1546
+ if len(ids_list) == 1:
1547
+ # Use single-delete endpoint for one product
1548
+ url = f"{_get_testmonitor_base_url()}/products/{ids_list[0]}"
1549
+ resp = make_api_request("DELETE", url)
1550
+ if resp.status_code == 204 or resp.status_code == 200:
1551
+ click.echo("✓ Product deleted successfully.")
1552
+ else:
1553
+ resp.raise_for_status()
1554
+ else:
1555
+ # Use bulk-delete endpoint for multiple products
1556
+ url = f"{_get_testmonitor_base_url()}/delete-products"
1557
+ payload: Dict[str, Any] = {"ids": ids_list}
1558
+ resp = make_api_request("POST", url, payload=payload)
1559
+
1560
+ if resp.status_code == 204:
1561
+ click.echo(f"✓ {len(ids_list)} product(s) deleted successfully.")
1562
+ return
1563
+
1564
+ try:
1565
+ data = resp.json()
1566
+ except Exception:
1567
+ data = {}
1568
+
1569
+ if resp.status_code == 200:
1570
+ deleted = data.get("ids", [])
1571
+ failed = data.get("failed", [])
1572
+ if deleted:
1573
+ click.echo(f"✓ Deleted {len(deleted)} product(s): {', '.join(deleted)}")
1574
+ if failed:
1575
+ click.echo(f"✗ Failed to delete: {', '.join(failed)}", err=True)
1576
+ sys.exit(ExitCodes.GENERAL_ERROR)
1577
+ else:
1578
+ resp.raise_for_status()
1579
+
1580
+ except SystemExit:
1581
+ raise
1582
+ except Exception as exc: # noqa: BLE001
1583
+ handle_api_error(exc)
1584
+
1585
+ @result.command(name="get")
1586
+ @click.argument("result_id")
1587
+ @click.option("--include-steps", is_flag=True, help="Include step details in output.")
1588
+ @click.option(
1589
+ "--include-measurements",
1590
+ is_flag=True,
1591
+ help="Include measurement data from steps.",
1592
+ )
1593
+ @click.option("--format", "-f", type=click.Choice(["table", "json"]), default="table")
1594
+ def get_result(
1595
+ result_id: str, include_steps: bool, include_measurements: bool, format: str
1596
+ ) -> None:
1597
+ """Get detailed information about a specific test result.
1598
+
1599
+ Args:
1600
+ result_id: The ID of the result to retrieve.
1601
+ include_steps: Include step details in output.
1602
+ include_measurements: Include measurement data from steps.
1603
+ format: Output format (table or json).
1604
+ """
1605
+ try:
1606
+ validate_output_format(format)
1607
+ url = f"{_get_testmonitor_base_url()}/results/{result_id}"
1608
+ resp = make_api_request("GET", url)
1609
+ resp.raise_for_status()
1610
+
1611
+ result = resp.json()
1612
+
1613
+ # Fetch steps if requested
1614
+ steps: List[Dict[str, Any]] = []
1615
+ if include_steps or include_measurements:
1616
+ steps_url = f"{_get_testmonitor_base_url()}/query-steps"
1617
+ steps_body = {"filter": "resultId == @0", "substitutions": [result_id]}
1618
+ steps_resp = make_api_request("POST", steps_url, payload=steps_body)
1619
+ steps_resp.raise_for_status()
1620
+ steps = steps_resp.json().get("steps", [])
1621
+ result["steps"] = steps
1622
+
1623
+ if format == "json":
1624
+ click.echo(json.dumps(result, indent=2))
1625
+ else:
1626
+ # Table format - detailed view
1627
+ status_value = result.get("status", {})
1628
+ if isinstance(status_value, dict):
1629
+ status_type = status_value.get("statusType", "N/A")
1630
+ else:
1631
+ status_type = "N/A" if status_value is None else str(status_value)
1632
+ click.echo(f"\nTest Result: {result.get('programName', 'N/A')} ({result_id})")
1633
+ click.echo(f"Status: {status_type}")
1634
+ click.echo(f"Part Number: {result.get('partNumber', 'N/A')}")
1635
+ click.echo(f"Serial Number: {result.get('serialNumber', 'N/A')}")
1636
+ click.echo(f"Started: {_format_date(result.get('startedAt', 'N/A'))}")
1637
+ click.echo(f"Updated: {_format_date(result.get('updatedAt', 'N/A'))}")
1638
+ click.echo(f"Duration: {_format_duration(result.get('totalTimeInSeconds', 0))}")
1639
+ click.echo(f"System ID: {result.get('systemId', 'N/A')}")
1640
+ click.echo(f"Host: {result.get('hostName', 'N/A')}")
1641
+ click.echo(f"Operator: {result.get('operator', 'N/A')}")
1642
+
1643
+ # Display steps if requested or when measurements are requested
1644
+ if (include_steps or include_measurements) and steps:
1645
+ click.echo("\nSteps:")
1646
+ for i, step in enumerate(steps, 1):
1647
+ step_status_value = step.get("status", {})
1648
+ if isinstance(step_status_value, dict):
1649
+ step_status_type = step_status_value.get("statusType", "N/A")
1650
+ else:
1651
+ step_status_type = (
1652
+ "N/A" if step_status_value is None else str(step_status_value)
1653
+ )
1654
+ click.echo(
1655
+ f" {i}. {step.get('name', 'N/A')} [{step_status_type}] "
1656
+ f"({_format_duration(step.get('totalTimeInSeconds', 0))})"
1657
+ )
1658
+
1659
+ # Display measurements if requested
1660
+ if include_measurements and step.get("outputs"):
1661
+ for output in step["outputs"]:
1662
+ output_name = output.get("name", "N/A")
1663
+ output_value = output.get("value", "N/A")
1664
+ click.echo(f" • {output_name}: {output_value}")
1665
+ click.echo()
1666
+ except Exception as exc: # noqa: BLE001
1667
+ handle_api_error(exc)