systemlink-cli 1.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|