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
slcli/tag_click.py
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
"""CLI commands for managing SystemLink tags.
|
|
2
|
+
|
|
3
|
+
Provides CLI commands for creating, reading, updating, deleting, and managing
|
|
4
|
+
tag values. All tag operations are scoped to workspaces with proper error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import questionary
|
|
15
|
+
|
|
16
|
+
from .cli_utils import validate_output_format
|
|
17
|
+
from .universal_handlers import FilteredResponse, UniversalResponseHandler
|
|
18
|
+
from .utils import (
|
|
19
|
+
ExitCodes,
|
|
20
|
+
format_success,
|
|
21
|
+
get_base_url,
|
|
22
|
+
handle_api_error,
|
|
23
|
+
make_api_request,
|
|
24
|
+
)
|
|
25
|
+
from .workspace_utils import resolve_workspace_id
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _tag_formatter(item: Dict[str, Any]) -> List[str]:
|
|
29
|
+
"""Format a tag for table output.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
item: Tag dictionary
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
List of formatted column values
|
|
36
|
+
"""
|
|
37
|
+
# item might be a TagWithValue object where tag props are in 'tag' field
|
|
38
|
+
tag_data = item.get("tag", item)
|
|
39
|
+
|
|
40
|
+
path = tag_data.get("path", "")
|
|
41
|
+
tag_type = tag_data.get("type", "")
|
|
42
|
+
last_updated = tag_data.get("lastUpdated", "")
|
|
43
|
+
|
|
44
|
+
# Get current value
|
|
45
|
+
current = item.get("current", {})
|
|
46
|
+
if current and current.get("value"):
|
|
47
|
+
value_obj = current.get("value", {})
|
|
48
|
+
value = str(value_obj.get("value", "N/A"))
|
|
49
|
+
else:
|
|
50
|
+
value = "N/A"
|
|
51
|
+
|
|
52
|
+
return [path, tag_type, value, last_updated]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _calculate_column_widths() -> List[int]:
|
|
56
|
+
"""Calculate dynamic column widths based on terminal size.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
List of column widths: [path_width, type_width, value_width, last_updated_width]
|
|
60
|
+
"""
|
|
61
|
+
# Get terminal width, default to 120 if detection fails
|
|
62
|
+
try:
|
|
63
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
64
|
+
except Exception:
|
|
65
|
+
terminal_width = 120
|
|
66
|
+
|
|
67
|
+
# Fixed widths for non-path columns
|
|
68
|
+
type_width = 12
|
|
69
|
+
value_width = 30
|
|
70
|
+
last_updated_width = 20
|
|
71
|
+
|
|
72
|
+
# Account for table borders and padding for 4 columns.
|
|
73
|
+
# Row layout: "ā {col1} ā {col2} ā {col3} ā {col4} ā"
|
|
74
|
+
# This is 5 vertical bars (ā) and 8 spaces (2 per column) = 13 characters.
|
|
75
|
+
# Using 14 to account for terminal rendering variations.
|
|
76
|
+
border_overhead = 14
|
|
77
|
+
|
|
78
|
+
# Calculate remaining space for path
|
|
79
|
+
fixed_columns = type_width + value_width + last_updated_width
|
|
80
|
+
path_width = terminal_width - fixed_columns - border_overhead
|
|
81
|
+
|
|
82
|
+
# Ensure minimum path width of 30, maximum of 100
|
|
83
|
+
path_width = max(30, min(100, path_width))
|
|
84
|
+
|
|
85
|
+
return [path_width, type_width, value_width, last_updated_width]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _escape_query_value(value: str) -> str:
|
|
89
|
+
"""Escape double quotes in query filter values.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
value: Raw filter value
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Escaped value safe for use in query strings
|
|
96
|
+
"""
|
|
97
|
+
return value.replace('"', '\\"')
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_keywords(keywords: Optional[str]) -> List[str]:
|
|
101
|
+
"""Parse comma-separated keywords string into list.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
keywords: Comma-separated keywords string
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
List of trimmed keyword strings
|
|
108
|
+
"""
|
|
109
|
+
if not keywords:
|
|
110
|
+
return []
|
|
111
|
+
return [k.strip() for k in keywords.split(",") if k.strip()]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_properties(properties: tuple) -> Dict[str, str]:
|
|
115
|
+
"""Parse properties tuple into dictionary.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
properties: Tuple of key=value strings
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Dictionary of property key-value pairs
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValueError: If property format is invalid
|
|
125
|
+
"""
|
|
126
|
+
properties_dict: Dict[str, str] = {}
|
|
127
|
+
for prop in properties:
|
|
128
|
+
if "=" not in prop:
|
|
129
|
+
raise ValueError(f"Invalid property format: {prop}. Use key=value")
|
|
130
|
+
key, val = prop.split("=", 1)
|
|
131
|
+
properties_dict[key.strip()] = val.strip()
|
|
132
|
+
return properties_dict
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _detect_value_type(value_str: str) -> Tuple[Any, str]:
|
|
136
|
+
"""Detect the type of a value from its string representation.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
value_str: String representation of the value
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple of (converted_value, type_string) where type_string is
|
|
143
|
+
'BOOLEAN', 'INT', 'DOUBLE', or 'STRING'
|
|
144
|
+
"""
|
|
145
|
+
# Check for boolean
|
|
146
|
+
if value_str.lower() in ("true", "false"):
|
|
147
|
+
is_true = value_str.lower() == "true"
|
|
148
|
+
return is_true, "BOOLEAN"
|
|
149
|
+
|
|
150
|
+
# Check for integer (excluding scientific notation)
|
|
151
|
+
if "." not in value_str and "e" not in value_str.lower():
|
|
152
|
+
try:
|
|
153
|
+
int_val = int(value_str)
|
|
154
|
+
return int_val, "INT"
|
|
155
|
+
except ValueError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
# Check for double/float
|
|
159
|
+
try:
|
|
160
|
+
float_val = float(value_str)
|
|
161
|
+
return float_val, "DOUBLE"
|
|
162
|
+
except ValueError:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
# Default to string
|
|
166
|
+
return value_str, "STRING"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def register_tag_commands(cli: Any) -> None:
|
|
170
|
+
"""Register the 'tag' command group and its subcommands."""
|
|
171
|
+
|
|
172
|
+
@cli.group()
|
|
173
|
+
def tag() -> None:
|
|
174
|
+
"""Manage SystemLink tags."""
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
@tag.command(name="list")
|
|
178
|
+
@click.option(
|
|
179
|
+
"--workspace",
|
|
180
|
+
"-w",
|
|
181
|
+
type=str,
|
|
182
|
+
default=None,
|
|
183
|
+
help="Workspace ID or name (defaults to default workspace)",
|
|
184
|
+
)
|
|
185
|
+
@click.option(
|
|
186
|
+
"--format",
|
|
187
|
+
"-f",
|
|
188
|
+
type=click.Choice(["table", "json"]),
|
|
189
|
+
default="table",
|
|
190
|
+
show_default=True,
|
|
191
|
+
help="Output format",
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"--filter",
|
|
195
|
+
type=str,
|
|
196
|
+
default=None,
|
|
197
|
+
help="Filter by tag path substring (e.g., 'temperature')",
|
|
198
|
+
)
|
|
199
|
+
@click.option(
|
|
200
|
+
"--keywords",
|
|
201
|
+
type=str,
|
|
202
|
+
default=None,
|
|
203
|
+
help="Comma-separated keywords to filter by",
|
|
204
|
+
)
|
|
205
|
+
@click.option(
|
|
206
|
+
"--take",
|
|
207
|
+
"-t",
|
|
208
|
+
type=int,
|
|
209
|
+
default=None,
|
|
210
|
+
help="Limit number of results (table: 25, json: 1000)",
|
|
211
|
+
)
|
|
212
|
+
def list_tags(
|
|
213
|
+
workspace: Optional[str],
|
|
214
|
+
format: str,
|
|
215
|
+
filter: Optional[str],
|
|
216
|
+
keywords: Optional[str],
|
|
217
|
+
take: Optional[int],
|
|
218
|
+
) -> None:
|
|
219
|
+
"""List tags in a workspace with optional filtering."""
|
|
220
|
+
validate_output_format(format)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
ws_id = resolve_workspace_id(workspace)
|
|
224
|
+
|
|
225
|
+
# Build filter string
|
|
226
|
+
filter_parts = []
|
|
227
|
+
|
|
228
|
+
# Only add workspace filter if workspace is provided
|
|
229
|
+
if ws_id:
|
|
230
|
+
filter_parts.append(f'workspace = "{ws_id}"')
|
|
231
|
+
|
|
232
|
+
if filter:
|
|
233
|
+
escaped_filter = _escape_query_value(filter)
|
|
234
|
+
filter_parts.append(f'path = "*{escaped_filter}*"')
|
|
235
|
+
|
|
236
|
+
if keywords:
|
|
237
|
+
for k in keywords.split(","):
|
|
238
|
+
k_clean = k.strip()
|
|
239
|
+
if k_clean:
|
|
240
|
+
escaped_keyword = _escape_query_value(k_clean)
|
|
241
|
+
filter_parts.append(f'keywords.Contains("{escaped_keyword}")')
|
|
242
|
+
|
|
243
|
+
query_filter = " && ".join(filter_parts)
|
|
244
|
+
|
|
245
|
+
# Set defaults based on format
|
|
246
|
+
if take is None:
|
|
247
|
+
take = 25 if format == "table" else 1000
|
|
248
|
+
|
|
249
|
+
# Build query request
|
|
250
|
+
query_params: Dict[str, Any] = {
|
|
251
|
+
"filter": query_filter,
|
|
252
|
+
"take": take,
|
|
253
|
+
"orderBy": "TIMESTAMP",
|
|
254
|
+
"descending": True,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
url = f"{get_base_url()}/nitag/v2/query-tags-with-values"
|
|
258
|
+
resp = make_api_request("POST", url, payload=query_params)
|
|
259
|
+
data = resp.json()
|
|
260
|
+
|
|
261
|
+
tags = data.get("tagsWithValues", [])
|
|
262
|
+
total_count = data.get("totalCount", len(tags))
|
|
263
|
+
continuation_token = data.get("continuationToken")
|
|
264
|
+
|
|
265
|
+
# For table format with continuation, show interactive pagination
|
|
266
|
+
if format == "table" and continuation_token:
|
|
267
|
+
from .table_utils import output_formatted_list
|
|
268
|
+
|
|
269
|
+
cumulative_count = 0
|
|
270
|
+
column_widths = _calculate_column_widths()
|
|
271
|
+
|
|
272
|
+
while True:
|
|
273
|
+
# Display current page
|
|
274
|
+
output_formatted_list(
|
|
275
|
+
items=tags,
|
|
276
|
+
output_format="table",
|
|
277
|
+
headers=["Path", "Type", "Value", "Last Updated"],
|
|
278
|
+
row_formatter_func=_tag_formatter,
|
|
279
|
+
column_widths=column_widths,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Update cumulative count and show pagination info
|
|
283
|
+
cumulative_count += len(tags)
|
|
284
|
+
click.echo(f"\nShowing {cumulative_count} of {total_count} tags")
|
|
285
|
+
|
|
286
|
+
# Check if there are more results
|
|
287
|
+
if not continuation_token:
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
# Ask if user wants more
|
|
291
|
+
if questionary.confirm(f"Show next {take} results?", default=True).ask():
|
|
292
|
+
query_params["continuationToken"] = continuation_token
|
|
293
|
+
resp = make_api_request("POST", url, payload=query_params)
|
|
294
|
+
data = resp.json()
|
|
295
|
+
tags = data.get("tagsWithValues", [])
|
|
296
|
+
continuation_token = data.get("continuationToken")
|
|
297
|
+
|
|
298
|
+
if not tags:
|
|
299
|
+
click.echo("No more results.")
|
|
300
|
+
break
|
|
301
|
+
else:
|
|
302
|
+
break
|
|
303
|
+
else:
|
|
304
|
+
# No continuation or JSON format - use standard handler
|
|
305
|
+
column_widths = _calculate_column_widths()
|
|
306
|
+
combined_resp = FilteredResponse({"tagsWithValues": tags})
|
|
307
|
+
UniversalResponseHandler.handle_list_response(
|
|
308
|
+
resp=combined_resp,
|
|
309
|
+
data_key="tagsWithValues",
|
|
310
|
+
item_name="tag",
|
|
311
|
+
format_output=format,
|
|
312
|
+
formatter_func=_tag_formatter,
|
|
313
|
+
headers=["Path", "Type", "Value", "Last Updated"],
|
|
314
|
+
column_widths=column_widths,
|
|
315
|
+
enable_pagination=False,
|
|
316
|
+
page_size=25,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if format == "table" and total_count > len(tags):
|
|
320
|
+
click.echo(f"\nShowing {len(tags)} of {total_count} tags")
|
|
321
|
+
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
handle_api_error(exc)
|
|
324
|
+
|
|
325
|
+
@tag.command(name="get")
|
|
326
|
+
@click.argument("tag_path")
|
|
327
|
+
@click.option(
|
|
328
|
+
"--workspace",
|
|
329
|
+
"-w",
|
|
330
|
+
type=str,
|
|
331
|
+
default=None,
|
|
332
|
+
help="Workspace ID or name (defaults to default workspace)",
|
|
333
|
+
)
|
|
334
|
+
@click.option(
|
|
335
|
+
"--include-aggregates",
|
|
336
|
+
is_flag=True,
|
|
337
|
+
help="Include min/max/avg/count aggregates",
|
|
338
|
+
)
|
|
339
|
+
def get_tag(tag_path: str, workspace: Optional[str], include_aggregates: bool) -> None:
|
|
340
|
+
"""View tag metadata and current value.
|
|
341
|
+
|
|
342
|
+
TAG_PATH is the path identifier of the tag (e.g., 'system.temperature').
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
ws_id = resolve_workspace_id(workspace)
|
|
346
|
+
encoded_path = urllib.parse.quote(tag_path, safe="")
|
|
347
|
+
ws_path = f"{ws_id}/" if ws_id else ""
|
|
348
|
+
|
|
349
|
+
# Get tag metadata
|
|
350
|
+
url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}"
|
|
351
|
+
tag_resp = make_api_request("GET", url, payload=None)
|
|
352
|
+
tag_data = tag_resp.json()
|
|
353
|
+
|
|
354
|
+
# Get tag value with aggregates
|
|
355
|
+
value_url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}/values"
|
|
356
|
+
value_resp = make_api_request("GET", value_url, payload=None)
|
|
357
|
+
|
|
358
|
+
# Handle 204 No Content (tag has no value yet)
|
|
359
|
+
value_data = {} if value_resp.status_code == 204 else value_resp.json()
|
|
360
|
+
|
|
361
|
+
click.echo(f"\nā Tag: {tag_path}")
|
|
362
|
+
click.echo("-" * 60)
|
|
363
|
+
click.echo(f" Type: {tag_data.get('type', 'Unknown')}")
|
|
364
|
+
click.echo(f" Workspace: {ws_id}")
|
|
365
|
+
|
|
366
|
+
keywords = tag_data.get("keywords", [])
|
|
367
|
+
if keywords:
|
|
368
|
+
click.echo(f" Keywords: {', '.join(keywords)}")
|
|
369
|
+
|
|
370
|
+
properties = tag_data.get("properties", {})
|
|
371
|
+
if properties:
|
|
372
|
+
click.echo(f" Properties:")
|
|
373
|
+
for key, val in properties.items():
|
|
374
|
+
click.echo(f" {key}: {val}")
|
|
375
|
+
|
|
376
|
+
click.echo(f" Last Updated: {tag_data.get('lastUpdated', 'N/A')}")
|
|
377
|
+
click.echo(f" Collect Aggregates: {tag_data.get('collectAggregates', False)}")
|
|
378
|
+
|
|
379
|
+
# Show current value
|
|
380
|
+
current = value_data.get("current")
|
|
381
|
+
if current:
|
|
382
|
+
value_obj = current.get("value", {})
|
|
383
|
+
click.echo(f"\n Current Value:")
|
|
384
|
+
click.echo(f" Value: {value_obj.get('value', 'N/A')}")
|
|
385
|
+
click.echo(f" Timestamp: {current.get('timestamp', 'N/A')}")
|
|
386
|
+
else:
|
|
387
|
+
click.echo(f"\n Current Value: No value assigned yet")
|
|
388
|
+
|
|
389
|
+
# Show aggregates if requested
|
|
390
|
+
if include_aggregates:
|
|
391
|
+
aggregates = value_data.get("aggregates", {})
|
|
392
|
+
if aggregates:
|
|
393
|
+
click.echo(f"\n Aggregates:")
|
|
394
|
+
click.echo(f" Min: {aggregates.get('min', 'N/A')}")
|
|
395
|
+
click.echo(f" Max: {aggregates.get('max', 'N/A')}")
|
|
396
|
+
click.echo(f" Avg: {aggregates.get('avg', 'N/A')}")
|
|
397
|
+
click.echo(f" Count: {aggregates.get('count', 'N/A')}")
|
|
398
|
+
click.echo()
|
|
399
|
+
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
handle_api_error(exc)
|
|
402
|
+
|
|
403
|
+
@tag.command(name="create")
|
|
404
|
+
@click.argument("tag_path")
|
|
405
|
+
@click.option(
|
|
406
|
+
"--type",
|
|
407
|
+
"-t",
|
|
408
|
+
"tag_type",
|
|
409
|
+
type=click.Choice(["DOUBLE", "INT", "STRING", "BOOLEAN", "U_INT64", "DATE_TIME"]),
|
|
410
|
+
required=True,
|
|
411
|
+
help="Tag data type",
|
|
412
|
+
)
|
|
413
|
+
@click.option(
|
|
414
|
+
"--workspace",
|
|
415
|
+
"-w",
|
|
416
|
+
type=str,
|
|
417
|
+
default=None,
|
|
418
|
+
help="Workspace ID or name (defaults to default workspace)",
|
|
419
|
+
)
|
|
420
|
+
@click.option(
|
|
421
|
+
"--keywords",
|
|
422
|
+
"-k",
|
|
423
|
+
type=str,
|
|
424
|
+
default=None,
|
|
425
|
+
help="Comma-separated keywords",
|
|
426
|
+
)
|
|
427
|
+
@click.option(
|
|
428
|
+
"--properties",
|
|
429
|
+
"-p",
|
|
430
|
+
type=str,
|
|
431
|
+
multiple=True,
|
|
432
|
+
help="Properties as key=value (can be used multiple times)",
|
|
433
|
+
)
|
|
434
|
+
@click.option(
|
|
435
|
+
"--collect-aggregates",
|
|
436
|
+
is_flag=True,
|
|
437
|
+
help="Enable aggregate value collection",
|
|
438
|
+
)
|
|
439
|
+
def create_tag(
|
|
440
|
+
tag_path: str,
|
|
441
|
+
tag_type: str,
|
|
442
|
+
workspace: Optional[str],
|
|
443
|
+
keywords: Optional[str],
|
|
444
|
+
properties: tuple,
|
|
445
|
+
collect_aggregates: bool,
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Create a new tag."""
|
|
448
|
+
from .utils import check_readonly_mode
|
|
449
|
+
|
|
450
|
+
check_readonly_mode("create a tag")
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
ws_id = resolve_workspace_id(workspace)
|
|
454
|
+
|
|
455
|
+
# Parse keywords and properties
|
|
456
|
+
keywords_list = _parse_keywords(keywords)
|
|
457
|
+
properties_dict = _parse_properties(properties)
|
|
458
|
+
|
|
459
|
+
# Create tag payload
|
|
460
|
+
tag_payload = {
|
|
461
|
+
"path": tag_path,
|
|
462
|
+
"type": tag_type,
|
|
463
|
+
"workspace": ws_id,
|
|
464
|
+
"collectAggregates": collect_aggregates,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if keywords_list:
|
|
468
|
+
tag_payload["keywords"] = keywords_list
|
|
469
|
+
|
|
470
|
+
if properties_dict:
|
|
471
|
+
tag_payload["properties"] = properties_dict
|
|
472
|
+
|
|
473
|
+
encoded_path = urllib.parse.quote(tag_path, safe="")
|
|
474
|
+
ws_path = f"{ws_id}/" if ws_id else ""
|
|
475
|
+
url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}"
|
|
476
|
+
make_api_request("PUT", url, payload=tag_payload)
|
|
477
|
+
|
|
478
|
+
format_success("Tag created", {"path": tag_path, "type": tag_type, "workspace": ws_id})
|
|
479
|
+
|
|
480
|
+
except Exception as exc:
|
|
481
|
+
handle_api_error(exc)
|
|
482
|
+
|
|
483
|
+
@tag.command(name="update")
|
|
484
|
+
@click.argument("tag_path")
|
|
485
|
+
@click.option(
|
|
486
|
+
"--workspace",
|
|
487
|
+
"-w",
|
|
488
|
+
type=str,
|
|
489
|
+
default=None,
|
|
490
|
+
help="Workspace ID or name (defaults to default workspace)",
|
|
491
|
+
)
|
|
492
|
+
@click.option(
|
|
493
|
+
"--keywords",
|
|
494
|
+
"-k",
|
|
495
|
+
type=str,
|
|
496
|
+
default=None,
|
|
497
|
+
help="Comma-separated keywords",
|
|
498
|
+
)
|
|
499
|
+
@click.option(
|
|
500
|
+
"--properties",
|
|
501
|
+
"-p",
|
|
502
|
+
type=str,
|
|
503
|
+
multiple=True,
|
|
504
|
+
help="Properties as key=value (can be used multiple times)",
|
|
505
|
+
)
|
|
506
|
+
@click.option(
|
|
507
|
+
"--merge",
|
|
508
|
+
is_flag=True,
|
|
509
|
+
help="Merge with existing keywords/properties (vs replace)",
|
|
510
|
+
)
|
|
511
|
+
def update_tag(
|
|
512
|
+
tag_path: str,
|
|
513
|
+
workspace: Optional[str],
|
|
514
|
+
keywords: Optional[str],
|
|
515
|
+
properties: tuple,
|
|
516
|
+
merge: bool,
|
|
517
|
+
) -> None:
|
|
518
|
+
"""Update tag metadata (keywords, properties)."""
|
|
519
|
+
from .utils import check_readonly_mode
|
|
520
|
+
|
|
521
|
+
check_readonly_mode("update a tag")
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
ws_id = resolve_workspace_id(workspace)
|
|
525
|
+
|
|
526
|
+
# Parse keywords and properties
|
|
527
|
+
keywords_list = _parse_keywords(keywords)
|
|
528
|
+
properties_dict = _parse_properties(properties)
|
|
529
|
+
|
|
530
|
+
# Validate at least one field is provided
|
|
531
|
+
if not keywords_list and not properties_dict:
|
|
532
|
+
click.echo(
|
|
533
|
+
"ā Error: At least one of --keywords or --properties must be specified",
|
|
534
|
+
err=True,
|
|
535
|
+
)
|
|
536
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
537
|
+
|
|
538
|
+
# Create update payload
|
|
539
|
+
tag_update: Dict[str, Any] = {
|
|
540
|
+
"path": tag_path,
|
|
541
|
+
}
|
|
542
|
+
if keywords_list:
|
|
543
|
+
tag_update["keywords"] = keywords_list
|
|
544
|
+
if properties_dict:
|
|
545
|
+
tag_update["properties"] = properties_dict
|
|
546
|
+
|
|
547
|
+
update_payload = {
|
|
548
|
+
"tags": [tag_update],
|
|
549
|
+
"merge": merge,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
url = f"{get_base_url()}/nitag/v2/update-tags"
|
|
553
|
+
make_api_request("POST", url, payload=update_payload)
|
|
554
|
+
|
|
555
|
+
format_success("Tag updated", {"path": tag_path, "workspace": ws_id})
|
|
556
|
+
|
|
557
|
+
except Exception as exc:
|
|
558
|
+
handle_api_error(exc)
|
|
559
|
+
|
|
560
|
+
@tag.command(name="delete")
|
|
561
|
+
@click.argument("tag_path")
|
|
562
|
+
@click.option(
|
|
563
|
+
"--workspace",
|
|
564
|
+
"-w",
|
|
565
|
+
type=str,
|
|
566
|
+
default=None,
|
|
567
|
+
help="Workspace ID or name (defaults to default workspace)",
|
|
568
|
+
)
|
|
569
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this tag?")
|
|
570
|
+
def delete_tag(tag_path: str, workspace: Optional[str]) -> None:
|
|
571
|
+
"""Delete a tag.
|
|
572
|
+
|
|
573
|
+
TAG_PATH is the path identifier of the tag to delete.
|
|
574
|
+
"""
|
|
575
|
+
from .utils import check_readonly_mode
|
|
576
|
+
|
|
577
|
+
check_readonly_mode("delete a tag")
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
ws_id = resolve_workspace_id(workspace)
|
|
581
|
+
encoded_path = urllib.parse.quote(tag_path, safe="")
|
|
582
|
+
ws_path = f"{ws_id}/" if ws_id else ""
|
|
583
|
+
|
|
584
|
+
url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}"
|
|
585
|
+
make_api_request("DELETE", url, payload=None)
|
|
586
|
+
|
|
587
|
+
format_success("Tag deleted", {"path": tag_path, "workspace": ws_id})
|
|
588
|
+
|
|
589
|
+
except Exception as exc:
|
|
590
|
+
handle_api_error(exc)
|
|
591
|
+
|
|
592
|
+
@tag.command(name="set-value")
|
|
593
|
+
@click.argument("tag_path")
|
|
594
|
+
@click.argument("value")
|
|
595
|
+
@click.option(
|
|
596
|
+
"--workspace",
|
|
597
|
+
"-w",
|
|
598
|
+
type=str,
|
|
599
|
+
default=None,
|
|
600
|
+
help="Workspace ID or name (defaults to default workspace)",
|
|
601
|
+
)
|
|
602
|
+
@click.option(
|
|
603
|
+
"--type",
|
|
604
|
+
"-t",
|
|
605
|
+
"data_type",
|
|
606
|
+
type=click.Choice(["DOUBLE", "INT", "STRING", "BOOLEAN", "U_INT64", "DATE_TIME"]),
|
|
607
|
+
default=None,
|
|
608
|
+
help="Override the value data type (auto-detected from the tag definition by default)",
|
|
609
|
+
)
|
|
610
|
+
@click.option(
|
|
611
|
+
"--timestamp",
|
|
612
|
+
type=str,
|
|
613
|
+
default=None,
|
|
614
|
+
help="Timestamp in ISO-8601 format (defaults to now)",
|
|
615
|
+
)
|
|
616
|
+
def set_tag_value(
|
|
617
|
+
tag_path: str,
|
|
618
|
+
value: str,
|
|
619
|
+
workspace: Optional[str],
|
|
620
|
+
data_type: Optional[str],
|
|
621
|
+
timestamp: Optional[str],
|
|
622
|
+
) -> None:
|
|
623
|
+
"""Write a value to a tag.
|
|
624
|
+
|
|
625
|
+
TAG_PATH is the path identifier of the tag.
|
|
626
|
+
VALUE is the value to write.
|
|
627
|
+
|
|
628
|
+
If you receive a "Conflict" error, the inferred type does not match the
|
|
629
|
+
tag's registered data type. Use --type to specify the correct type explicitly.
|
|
630
|
+
|
|
631
|
+
\b
|
|
632
|
+
Auto-detected value type (when --type is not provided):
|
|
633
|
+
- 'true' or 'false' (case-insensitive) -> BOOLEAN
|
|
634
|
+
- Integer numbers -> INT
|
|
635
|
+
- Decimal numbers -> DOUBLE
|
|
636
|
+
- Everything else -> STRING
|
|
637
|
+
"""
|
|
638
|
+
try:
|
|
639
|
+
ws_id = resolve_workspace_id(workspace)
|
|
640
|
+
encoded_path = urllib.parse.quote(tag_path, safe="")
|
|
641
|
+
ws_path = f"{ws_id}/" if ws_id else ""
|
|
642
|
+
|
|
643
|
+
if data_type:
|
|
644
|
+
# User explicitly specified the type ā no need to fetch metadata
|
|
645
|
+
tag_type: Optional[str] = data_type
|
|
646
|
+
else:
|
|
647
|
+
# Retrieve tag metadata to align value type with the tag definition
|
|
648
|
+
tag_meta_url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}"
|
|
649
|
+
tag_resp = make_api_request("GET", tag_meta_url, payload=None)
|
|
650
|
+
tag_data = tag_resp.json()
|
|
651
|
+
tag_type = tag_data.get("type")
|
|
652
|
+
|
|
653
|
+
# Detect value type and convert (used for converted_value and as fallback type)
|
|
654
|
+
converted_value, value_type = _detect_value_type(value)
|
|
655
|
+
|
|
656
|
+
# Always use the tag's registered type (or user-supplied --type) when available,
|
|
657
|
+
# so the API receives the correct type even if auto-detection disagrees.
|
|
658
|
+
if tag_type:
|
|
659
|
+
value_type = tag_type
|
|
660
|
+
|
|
661
|
+
# API expects value as string
|
|
662
|
+
api_value_str = value
|
|
663
|
+
|
|
664
|
+
# If the tag is U_INT64, enforce non-negative integer and set correct type
|
|
665
|
+
if tag_type == "U_INT64":
|
|
666
|
+
try:
|
|
667
|
+
numeric_val = int(value)
|
|
668
|
+
except ValueError:
|
|
669
|
+
click.echo(
|
|
670
|
+
"ā Error: U_INT64 tags require a non-negative integer value",
|
|
671
|
+
err=True,
|
|
672
|
+
)
|
|
673
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
674
|
+
|
|
675
|
+
if numeric_val < 0:
|
|
676
|
+
click.echo(
|
|
677
|
+
"ā Error: U_INT64 tags require a non-negative integer value",
|
|
678
|
+
err=True,
|
|
679
|
+
)
|
|
680
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
681
|
+
|
|
682
|
+
converted_value = numeric_val
|
|
683
|
+
api_value_str = value
|
|
684
|
+
elif tag_type == "DATE_TIME":
|
|
685
|
+
# For date-time tags, pass the value through as-is
|
|
686
|
+
converted_value = value
|
|
687
|
+
api_value_str = value
|
|
688
|
+
elif value_type == "BOOLEAN":
|
|
689
|
+
# Normalize boolean string values to lowercase
|
|
690
|
+
api_value_str = "true" if converted_value else "false"
|
|
691
|
+
|
|
692
|
+
# Create value payload
|
|
693
|
+
value_payload: Dict[str, Any] = {
|
|
694
|
+
"value": {
|
|
695
|
+
"value": api_value_str,
|
|
696
|
+
"type": value_type,
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if timestamp:
|
|
701
|
+
value_payload["timestamp"] = timestamp
|
|
702
|
+
|
|
703
|
+
url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}/values/current"
|
|
704
|
+
make_api_request("PUT", url, payload=value_payload)
|
|
705
|
+
|
|
706
|
+
# make_api_request raises on HTTP error status codes, so if we reach here it succeeded
|
|
707
|
+
format_success(
|
|
708
|
+
"Tag value updated",
|
|
709
|
+
{"path": tag_path, "value": converted_value, "type": value_type},
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
except Exception as exc:
|
|
713
|
+
handle_api_error(exc)
|
|
714
|
+
|
|
715
|
+
@tag.command(name="get-value")
|
|
716
|
+
@click.argument("tag_path")
|
|
717
|
+
@click.option(
|
|
718
|
+
"--workspace",
|
|
719
|
+
"-w",
|
|
720
|
+
type=str,
|
|
721
|
+
default=None,
|
|
722
|
+
help="Workspace ID or name (defaults to default workspace)",
|
|
723
|
+
)
|
|
724
|
+
@click.option(
|
|
725
|
+
"--include-aggregates",
|
|
726
|
+
is_flag=True,
|
|
727
|
+
help="Include min/max/avg/count aggregates",
|
|
728
|
+
)
|
|
729
|
+
@click.option(
|
|
730
|
+
"--format",
|
|
731
|
+
"-f",
|
|
732
|
+
type=click.Choice(["table", "json"]),
|
|
733
|
+
default="table",
|
|
734
|
+
show_default=True,
|
|
735
|
+
help="Output format",
|
|
736
|
+
)
|
|
737
|
+
def get_tag_value(
|
|
738
|
+
tag_path: str,
|
|
739
|
+
workspace: Optional[str],
|
|
740
|
+
include_aggregates: bool,
|
|
741
|
+
format: str,
|
|
742
|
+
) -> None:
|
|
743
|
+
"""Read the current value of a tag.
|
|
744
|
+
|
|
745
|
+
TAG_PATH is the path identifier of the tag.
|
|
746
|
+
"""
|
|
747
|
+
validate_output_format(format)
|
|
748
|
+
|
|
749
|
+
try:
|
|
750
|
+
ws_id = resolve_workspace_id(workspace)
|
|
751
|
+
encoded_path = urllib.parse.quote(tag_path, safe="")
|
|
752
|
+
ws_path = f"{ws_id}/" if ws_id else ""
|
|
753
|
+
|
|
754
|
+
url = f"{get_base_url()}/nitag/v2/tags/{ws_path}{encoded_path}/values"
|
|
755
|
+
resp = make_api_request("GET", url, payload=None)
|
|
756
|
+
|
|
757
|
+
# Handle 204 No Content (tag has no value yet)
|
|
758
|
+
if resp.status_code == 204:
|
|
759
|
+
if format.lower() == "json":
|
|
760
|
+
click.echo("{}")
|
|
761
|
+
else:
|
|
762
|
+
click.echo("No value found")
|
|
763
|
+
return
|
|
764
|
+
|
|
765
|
+
value_data = resp.json()
|
|
766
|
+
|
|
767
|
+
if format.lower() == "json":
|
|
768
|
+
click.echo(json.dumps(value_data, indent=2))
|
|
769
|
+
else:
|
|
770
|
+
# Table format
|
|
771
|
+
current = value_data.get("current")
|
|
772
|
+
if not current:
|
|
773
|
+
click.echo("No value found")
|
|
774
|
+
return
|
|
775
|
+
|
|
776
|
+
value_obj = current.get("value", {})
|
|
777
|
+
click.echo(f"\nā Tag Value: {tag_path}")
|
|
778
|
+
click.echo("-" * 60)
|
|
779
|
+
click.echo(f" Value: {value_obj.get('value', 'N/A')}")
|
|
780
|
+
click.echo(f" Type: {value_obj.get('type', 'N/A')}")
|
|
781
|
+
click.echo(f" Timestamp: {current.get('timestamp', 'N/A')}")
|
|
782
|
+
|
|
783
|
+
if include_aggregates:
|
|
784
|
+
aggregates = value_data.get("aggregates", {})
|
|
785
|
+
if aggregates:
|
|
786
|
+
click.echo(f"\n Aggregates:")
|
|
787
|
+
click.echo(f" Min: {aggregates.get('min', 'N/A')}")
|
|
788
|
+
click.echo(f" Max: {aggregates.get('max', 'N/A')}")
|
|
789
|
+
click.echo(f" Avg: {aggregates.get('avg', 'N/A')}")
|
|
790
|
+
click.echo(f" Count: {aggregates.get('count', 'N/A')}")
|
|
791
|
+
click.echo()
|
|
792
|
+
|
|
793
|
+
except Exception as exc:
|
|
794
|
+
handle_api_error(exc)
|