systemlink-cli 1.6.3__tar.gz → 1.6.5__tar.gz
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.
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/PKG-INFO +1 -1
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/pyproject.toml +1 -1
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/_version.py +1 -1
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/config_click.py +3 -1
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/file_click.py +336 -38
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/main.py +4 -1
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/platform.py +34 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/SKILL.md +22 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/references/nimble-angular.md +14 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/LICENSE +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/dff-editor/editor.js +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/dff-editor/index.html +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/__init__.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/__main__.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/asset_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/cli_formatters.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/cli_utils.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/comment_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/completion_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/config.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/dff_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/dff_decorators.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/example_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/example_loader.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/example_provisioner.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/README.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/_schema/schema-v1.0.json +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-complete-workflow/README.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-test-plans/README.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-test-plans/config.yaml +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/feed_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/function_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/function_templates.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/mcp_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/mcp_server.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/notebook_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/policy_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/policy_utils.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/profiles.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/response_handlers.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/rich_output.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/routine_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skill_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/slcli/SKILL.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/slcli/references/filtering.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/ssl_trust.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/system_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/table_utils.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/tag_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/templates_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/testmonitor_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/universal_handlers.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/user_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/utils.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/web_editor.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/webapp_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workflow_preview.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workflows_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workitem_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workspace_click.py +0 -0
- {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workspace_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "systemlink-cli"
|
|
3
|
-
version = "1.6.
|
|
3
|
+
version = "1.6.5"
|
|
4
4
|
description = "SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates."
|
|
5
5
|
authors = ["Fred Visser <fred.visser@emerson.com>"]
|
|
6
6
|
packages = [{ include = "slcli" }]
|
|
@@ -116,7 +116,9 @@ def _add_profile_impl(
|
|
|
116
116
|
elif status["auth_valid"] is True:
|
|
117
117
|
click.echo(" API key: ✓ Authorized")
|
|
118
118
|
|
|
119
|
-
if status.get("
|
|
119
|
+
if status.get("file_query_endpoint") == "query-files":
|
|
120
|
+
click.echo(" File query: query-files")
|
|
121
|
+
elif status.get("elasticsearch_available") is False:
|
|
120
122
|
click.echo(" File query: query-files-linq (Elasticsearch unavailable)")
|
|
121
123
|
click.echo(
|
|
122
124
|
" 'slcli file list' will fall back automatically; 'slcli file query' requires search-files."
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""CLI commands for managing SystemLink files."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
import shutil
|
|
5
6
|
import sys
|
|
6
7
|
import threading
|
|
7
8
|
import time
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from typing import Any, Dict, List, Optional
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
11
|
+
from urllib.parse import urlencode
|
|
10
12
|
|
|
11
13
|
import click
|
|
12
14
|
import questionary
|
|
@@ -35,37 +37,149 @@ def _get_search_files_url() -> str:
|
|
|
35
37
|
return f"{_get_file_service_url()}/search-files"
|
|
36
38
|
|
|
37
39
|
|
|
38
|
-
def
|
|
39
|
-
"""
|
|
40
|
+
def _get_query_files_url() -> str:
|
|
41
|
+
"""Get the query-files endpoint URL."""
|
|
42
|
+
return f"{_get_file_service_url()}/query-files"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_file_query_endpoint_unavailable(exc: requests_lib.HTTPError) -> bool:
|
|
46
|
+
"""Return whether a file query endpoint is unavailable."""
|
|
40
47
|
return exc.response is not None and exc.response.status_code in (404, 501)
|
|
41
48
|
|
|
42
49
|
|
|
43
50
|
def _exit_search_files_required() -> None:
|
|
44
51
|
"""Exit with a user-facing message when search-files is unavailable."""
|
|
45
52
|
click.echo(
|
|
46
|
-
"✗ File query requires
|
|
53
|
+
"✗ File query requires search-files syntax, but this server does not support it.",
|
|
47
54
|
err=True,
|
|
48
55
|
)
|
|
49
56
|
click.echo(
|
|
50
|
-
" Use 'slcli file list' for automatic fallback
|
|
57
|
+
" Use 'slcli file list' for automatic endpoint fallback, or use a simpler filter.",
|
|
51
58
|
err=True,
|
|
52
59
|
)
|
|
53
60
|
sys.exit(ExitCodes.INVALID_INPUT)
|
|
54
61
|
|
|
55
62
|
|
|
63
|
+
def _build_query_files_url(take: int, workspace_id: Optional[str]) -> str:
|
|
64
|
+
"""Build the query-files URL with supported query parameters."""
|
|
65
|
+
params: Dict[str, Any] = {"take": take}
|
|
66
|
+
if workspace_id:
|
|
67
|
+
params["workspace"] = workspace_id
|
|
68
|
+
return f"{_get_query_files_url()}?{urlencode(params)}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_structured_query_take(take: int, has_client_side_filters: bool) -> int:
|
|
72
|
+
"""Return the query-files page size to use for fallback filtering."""
|
|
73
|
+
if has_client_side_filters:
|
|
74
|
+
return max(take * 4, 1000)
|
|
75
|
+
return take
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_file_extension(file_item: dict) -> str:
|
|
79
|
+
"""Extract the file extension from file metadata without the leading dot."""
|
|
80
|
+
file_name = _get_file_name(file_item)
|
|
81
|
+
return Path(file_name).suffix.lstrip(".")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _filter_files_by_name_or_extension(
|
|
85
|
+
files: List[dict],
|
|
86
|
+
needle: str,
|
|
87
|
+
take: int,
|
|
88
|
+
) -> List[dict]:
|
|
89
|
+
"""Filter files by name or extension using case-insensitive contains matching."""
|
|
90
|
+
needle_lower = needle.lower()
|
|
91
|
+
extension_needle = needle_lower.lstrip(".")
|
|
92
|
+
filtered: List[dict] = []
|
|
93
|
+
|
|
94
|
+
for file_item in files:
|
|
95
|
+
file_name = _get_file_name(file_item).lower()
|
|
96
|
+
file_extension = _get_file_extension(file_item).lower()
|
|
97
|
+
if needle_lower in file_name or extension_needle in file_extension:
|
|
98
|
+
filtered.append(file_item)
|
|
99
|
+
if len(filtered) >= take:
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
return filtered
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_structured_file_query(
|
|
106
|
+
workspace_id: Optional[str],
|
|
107
|
+
name_filter: Optional[str],
|
|
108
|
+
id_filter: Optional[str],
|
|
109
|
+
) -> Dict[str, Any]:
|
|
110
|
+
"""Build a query-files request body from high-level file filters.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
workspace_id: Optional workspace ID filter (handled in URL)
|
|
114
|
+
name_filter: Optional file name substring filter
|
|
115
|
+
id_filter: Optional comma-separated file IDs
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Structured query-files request body
|
|
119
|
+
"""
|
|
120
|
+
del workspace_id
|
|
121
|
+
|
|
122
|
+
payload: Dict[str, Any] = {}
|
|
123
|
+
|
|
124
|
+
if id_filter:
|
|
125
|
+
ids = [file_id.strip() for file_id in id_filter.split(",") if file_id.strip()]
|
|
126
|
+
if len(ids) == 1:
|
|
127
|
+
payload["idQuery"] = {"operation": "EQUAL", "value": ids[0]}
|
|
128
|
+
elif ids:
|
|
129
|
+
payload["idsQuery"] = {"operation": "EQUAL", "ids": ids}
|
|
130
|
+
|
|
131
|
+
del name_filter
|
|
132
|
+
|
|
133
|
+
return payload
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _query_files_structured(
|
|
137
|
+
take: int,
|
|
138
|
+
workspace_id: Optional[str],
|
|
139
|
+
name_filter: Optional[str],
|
|
140
|
+
id_filter: Optional[str],
|
|
141
|
+
) -> Any:
|
|
142
|
+
"""Query files using the SLS query-files endpoint."""
|
|
143
|
+
has_client_side_filters = bool(name_filter)
|
|
144
|
+
url = _build_query_files_url(
|
|
145
|
+
take=_get_structured_query_take(take, has_client_side_filters),
|
|
146
|
+
workspace_id=workspace_id,
|
|
147
|
+
)
|
|
148
|
+
payload = _build_structured_file_query(
|
|
149
|
+
workspace_id=workspace_id,
|
|
150
|
+
name_filter=name_filter,
|
|
151
|
+
id_filter=id_filter,
|
|
152
|
+
)
|
|
153
|
+
resp = make_api_request("POST", url, payload=payload, handle_errors=False)
|
|
154
|
+
|
|
155
|
+
if not name_filter:
|
|
156
|
+
return resp
|
|
157
|
+
|
|
158
|
+
data = resp.json()
|
|
159
|
+
filtered = _filter_files_by_name_or_extension(
|
|
160
|
+
data.get("availableFiles", []),
|
|
161
|
+
name_filter,
|
|
162
|
+
take,
|
|
163
|
+
)
|
|
164
|
+
return FilteredResponse({"availableFiles": filtered})
|
|
165
|
+
|
|
166
|
+
|
|
56
167
|
def _search_files_with_fallback(
|
|
57
168
|
payload: Dict[str, Any],
|
|
169
|
+
take: int,
|
|
58
170
|
workspace_id: Optional[str],
|
|
59
171
|
name_filter: Optional[str],
|
|
60
172
|
id_filter: Optional[str],
|
|
61
173
|
) -> Any:
|
|
62
|
-
"""Try search-files
|
|
174
|
+
"""Try search-files, then query-files, then query-files-linq.
|
|
63
175
|
|
|
64
|
-
The search-files endpoint
|
|
65
|
-
|
|
176
|
+
The preferred search-files endpoint is available on SLE. On SLS, the file
|
|
177
|
+
service exposes query-files instead. Older systems may still require the
|
|
178
|
+
query-files-linq fallback.
|
|
66
179
|
|
|
67
180
|
Args:
|
|
68
181
|
payload: The search-files request payload
|
|
182
|
+
take: Maximum number of files to return for fallback endpoints
|
|
69
183
|
workspace_id: Optional workspace ID filter
|
|
70
184
|
name_filter: Optional name filter string
|
|
71
185
|
id_filter: Optional comma-separated file IDs
|
|
@@ -78,13 +192,23 @@ def _search_files_with_fallback(
|
|
|
78
192
|
"POST", _get_search_files_url(), payload=payload, handle_errors=False
|
|
79
193
|
)
|
|
80
194
|
except requests_lib.HTTPError as exc:
|
|
81
|
-
if
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
195
|
+
if _is_file_query_endpoint_unavailable(exc):
|
|
196
|
+
try:
|
|
197
|
+
return _query_files_structured(
|
|
198
|
+
take=take,
|
|
199
|
+
workspace_id=workspace_id,
|
|
200
|
+
name_filter=name_filter,
|
|
201
|
+
id_filter=id_filter,
|
|
202
|
+
)
|
|
203
|
+
except requests_lib.HTTPError as structured_exc:
|
|
204
|
+
if _is_file_query_endpoint_unavailable(structured_exc):
|
|
205
|
+
return _query_files_linq_fallback(
|
|
206
|
+
take=take,
|
|
207
|
+
workspace_id=workspace_id,
|
|
208
|
+
name_filter=name_filter,
|
|
209
|
+
id_filter=id_filter,
|
|
210
|
+
)
|
|
211
|
+
raise
|
|
88
212
|
raise
|
|
89
213
|
|
|
90
214
|
|
|
@@ -144,6 +268,152 @@ def _query_files_linq_fallback(
|
|
|
144
268
|
return resp
|
|
145
269
|
|
|
146
270
|
|
|
271
|
+
def _get_file_by_id_via_query_files(file_id: str) -> Optional[dict]:
|
|
272
|
+
"""Get file metadata by ID using the query-files endpoint."""
|
|
273
|
+
url = _build_query_files_url(take=1, workspace_id=None)
|
|
274
|
+
payload = {"idQuery": {"operation": "EQUAL", "value": file_id}}
|
|
275
|
+
resp = make_api_request("POST", url, payload=payload, handle_errors=False)
|
|
276
|
+
data = resp.json()
|
|
277
|
+
files = data.get("availableFiles", [])
|
|
278
|
+
if files:
|
|
279
|
+
return files[0]
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _get_file_by_id_via_query_files_linq(file_id: str) -> Optional[dict]:
|
|
284
|
+
"""Get file metadata by ID using the query-files-linq endpoint."""
|
|
285
|
+
url = f"{_get_file_service_url()}/query-files-linq"
|
|
286
|
+
payload = {
|
|
287
|
+
"filter": f'id = "{file_id}"',
|
|
288
|
+
"take": 1,
|
|
289
|
+
}
|
|
290
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
291
|
+
data = resp.json()
|
|
292
|
+
files = data.get("availableFiles", [])
|
|
293
|
+
if files:
|
|
294
|
+
return files[0]
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _extract_search_filter_values(expression: str) -> List[str]:
|
|
299
|
+
"""Extract quoted values from a search-files filter clause."""
|
|
300
|
+
return [value for value in re.findall(r'"([^"]*)"', expression) if value]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _parse_case_insensitive_filter_value(field_name: str, value: str) -> Tuple[str, str]:
|
|
304
|
+
"""Parse a supported name or extension value into a client-side filter."""
|
|
305
|
+
if field_name == "extension":
|
|
306
|
+
if value.startswith("*") and value.endswith("*") and len(value) >= 2:
|
|
307
|
+
return ("contains", value[1:-1].lstrip("."))
|
|
308
|
+
if "*" in value:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
"Only contains wildcards of the form '*value*' are supported on this server."
|
|
311
|
+
)
|
|
312
|
+
return ("equal", value.lstrip("."))
|
|
313
|
+
|
|
314
|
+
if value.startswith("*") and value.endswith("*") and len(value) >= 2:
|
|
315
|
+
return ("contains", value[1:-1])
|
|
316
|
+
if "*" in value:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
"Only contains wildcards of the form '*value*' are supported on this server."
|
|
319
|
+
)
|
|
320
|
+
return ("equal", value)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _matches_case_insensitive_filter(
|
|
324
|
+
file_item: dict,
|
|
325
|
+
field_name: str,
|
|
326
|
+
operation: str,
|
|
327
|
+
value: str,
|
|
328
|
+
) -> bool:
|
|
329
|
+
"""Return whether a file matches a parsed case-insensitive client-side filter."""
|
|
330
|
+
candidate = (
|
|
331
|
+
_get_file_name(file_item) if field_name == "name" else _get_file_extension(file_item)
|
|
332
|
+
)
|
|
333
|
+
candidate_lower = candidate.lower()
|
|
334
|
+
value_lower = value.lower()
|
|
335
|
+
|
|
336
|
+
if operation == "contains":
|
|
337
|
+
return value_lower in candidate_lower
|
|
338
|
+
return candidate_lower == value_lower
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _apply_case_insensitive_query_filters(
|
|
342
|
+
files: List[dict],
|
|
343
|
+
client_filters: List[Tuple[str, str, str]],
|
|
344
|
+
take: int,
|
|
345
|
+
) -> List[dict]:
|
|
346
|
+
"""Apply parsed name/extension filters to a query-files response."""
|
|
347
|
+
if not client_filters:
|
|
348
|
+
return files[:take]
|
|
349
|
+
|
|
350
|
+
filtered: List[dict] = []
|
|
351
|
+
for file_item in files:
|
|
352
|
+
if all(
|
|
353
|
+
_matches_case_insensitive_filter(file_item, field_name, operation, value)
|
|
354
|
+
for field_name, operation, value in client_filters
|
|
355
|
+
):
|
|
356
|
+
filtered.append(file_item)
|
|
357
|
+
if len(filtered) >= take:
|
|
358
|
+
break
|
|
359
|
+
|
|
360
|
+
return filtered
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _convert_search_filter_to_query_files(
|
|
364
|
+
filter_query: Optional[str],
|
|
365
|
+
workspace_id: Optional[str],
|
|
366
|
+
) -> Tuple[Dict[str, Any], Optional[str], List[Tuple[str, str, str]]]:
|
|
367
|
+
"""Convert supported search-files expressions to a query-files request body."""
|
|
368
|
+
payload: Dict[str, Any] = {}
|
|
369
|
+
resolved_workspace = workspace_id
|
|
370
|
+
client_filters: List[Tuple[str, str, str]] = []
|
|
371
|
+
|
|
372
|
+
if not filter_query:
|
|
373
|
+
return payload, resolved_workspace, client_filters
|
|
374
|
+
|
|
375
|
+
clauses = [part.strip() for part in re.split(r"\s+AND\s+", filter_query, flags=re.IGNORECASE)]
|
|
376
|
+
|
|
377
|
+
for clause in clauses:
|
|
378
|
+
match = re.match(r"^(name|extension|id|workspaceId):\((.+)\)$", clause)
|
|
379
|
+
if not match:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
"Only name, extension, id, and workspaceId filters joined by AND are supported on this server."
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
field_name, expression = match.groups()
|
|
385
|
+
if re.search(r"\s+OR\s+", expression, flags=re.IGNORECASE):
|
|
386
|
+
raise ValueError(
|
|
387
|
+
"Only name, extension, id, and workspaceId filters joined by AND are supported on this server."
|
|
388
|
+
)
|
|
389
|
+
values = _extract_search_filter_values(expression)
|
|
390
|
+
if not values:
|
|
391
|
+
raise ValueError(f"Could not parse filter clause: {clause}")
|
|
392
|
+
|
|
393
|
+
if field_name == "workspaceId":
|
|
394
|
+
if len(values) != 1:
|
|
395
|
+
raise ValueError("workspaceId filters must specify exactly one workspace ID.")
|
|
396
|
+
if resolved_workspace and resolved_workspace != values[0]:
|
|
397
|
+
raise ValueError("Conflicting workspace filters were provided.")
|
|
398
|
+
resolved_workspace = values[0]
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
if field_name == "id":
|
|
402
|
+
if len(values) == 1:
|
|
403
|
+
payload["idQuery"] = {"operation": "EQUAL", "value": values[0]}
|
|
404
|
+
else:
|
|
405
|
+
payload["idsQuery"] = {"operation": "EQUAL", "ids": values}
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
if len(values) != 1:
|
|
409
|
+
raise ValueError(f"{field_name} filters support exactly one value on this server.")
|
|
410
|
+
|
|
411
|
+
operation, parsed_value = _parse_case_insensitive_filter_value(field_name, values[0])
|
|
412
|
+
client_filters.append((field_name, operation, parsed_value))
|
|
413
|
+
|
|
414
|
+
return payload, resolved_workspace, client_filters
|
|
415
|
+
|
|
416
|
+
|
|
147
417
|
def _format_file_size(size_bytes: Optional[int]) -> str:
|
|
148
418
|
"""Format file size in human-readable format.
|
|
149
419
|
|
|
@@ -217,10 +487,7 @@ def _get_file_size(file_item: dict) -> Optional[int]:
|
|
|
217
487
|
|
|
218
488
|
|
|
219
489
|
def _get_file_by_id(file_id: str) -> Optional[dict]:
|
|
220
|
-
"""Get file metadata by ID using query
|
|
221
|
-
|
|
222
|
-
The API doesn't have a GET /files/{id} endpoint, so we use
|
|
223
|
-
query-files-linq with an ID filter instead.
|
|
490
|
+
"""Get file metadata by ID using the best available query endpoint.
|
|
224
491
|
|
|
225
492
|
Args:
|
|
226
493
|
file_id: The file ID to look up
|
|
@@ -228,17 +495,12 @@ def _get_file_by_id(file_id: str) -> Optional[dict]:
|
|
|
228
495
|
Returns:
|
|
229
496
|
File metadata dictionary or None if not found
|
|
230
497
|
"""
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
data = resp.json()
|
|
238
|
-
files = data.get("availableFiles", [])
|
|
239
|
-
if files:
|
|
240
|
-
return files[0]
|
|
241
|
-
return None
|
|
498
|
+
try:
|
|
499
|
+
return _get_file_by_id_via_query_files(file_id)
|
|
500
|
+
except requests_lib.HTTPError as exc:
|
|
501
|
+
if _is_file_query_endpoint_unavailable(exc):
|
|
502
|
+
return _get_file_by_id_via_query_files_linq(file_id)
|
|
503
|
+
raise
|
|
242
504
|
|
|
243
505
|
|
|
244
506
|
def register_file_commands(cli: Any) -> None:
|
|
@@ -336,6 +598,7 @@ def register_file_commands(cli: Any) -> None:
|
|
|
336
598
|
# Try search-files, fall back to query-files-linq if unavailable
|
|
337
599
|
resp = _search_files_with_fallback(
|
|
338
600
|
payload=payload,
|
|
601
|
+
take=api_take,
|
|
339
602
|
workspace_id=workspace_id,
|
|
340
603
|
name_filter=name_filter,
|
|
341
604
|
id_filter=id_filter,
|
|
@@ -687,8 +950,9 @@ def register_file_commands(cli: Any) -> None:
|
|
|
687
950
|
|
|
688
951
|
try:
|
|
689
952
|
# Build request body for search-files
|
|
953
|
+
api_take = take if format_output.lower() == "json" else (take if take != 25 else 1000)
|
|
690
954
|
query_body: Dict[str, Any] = {
|
|
691
|
-
"take":
|
|
955
|
+
"take": api_take,
|
|
692
956
|
"orderByDescending": descending,
|
|
693
957
|
}
|
|
694
958
|
|
|
@@ -714,12 +978,43 @@ def register_file_commands(cli: Any) -> None:
|
|
|
714
978
|
else:
|
|
715
979
|
query_body["orderBy"] = "updated"
|
|
716
980
|
|
|
717
|
-
resp
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
981
|
+
resp: Any
|
|
982
|
+
try:
|
|
983
|
+
resp = make_api_request(
|
|
984
|
+
"POST",
|
|
985
|
+
_get_search_files_url(),
|
|
986
|
+
payload=query_body,
|
|
987
|
+
handle_errors=False,
|
|
988
|
+
)
|
|
989
|
+
except requests_lib.HTTPError as exc:
|
|
990
|
+
if not _is_file_query_endpoint_unavailable(exc):
|
|
991
|
+
raise
|
|
992
|
+
|
|
993
|
+
structured_query, structured_workspace, client_filters = (
|
|
994
|
+
_convert_search_filter_to_query_files(
|
|
995
|
+
filter_query=filter_query,
|
|
996
|
+
workspace_id=workspace_id,
|
|
997
|
+
)
|
|
998
|
+
)
|
|
999
|
+
structured_take = _get_structured_query_take(api_take, bool(client_filters))
|
|
1000
|
+
structured_resp = make_api_request(
|
|
1001
|
+
"POST",
|
|
1002
|
+
_build_query_files_url(structured_take, structured_workspace),
|
|
1003
|
+
payload=structured_query,
|
|
1004
|
+
)
|
|
1005
|
+
if client_filters:
|
|
1006
|
+
structured_data = structured_resp.json()
|
|
1007
|
+
resp = FilteredResponse(
|
|
1008
|
+
{
|
|
1009
|
+
"availableFiles": _apply_case_insensitive_query_filters(
|
|
1010
|
+
structured_data.get("availableFiles", []),
|
|
1011
|
+
client_filters,
|
|
1012
|
+
api_take,
|
|
1013
|
+
)
|
|
1014
|
+
}
|
|
1015
|
+
)
|
|
1016
|
+
else:
|
|
1017
|
+
resp = structured_resp
|
|
723
1018
|
|
|
724
1019
|
def file_formatter(file_item: dict) -> list:
|
|
725
1020
|
name = _get_file_name(file_item)
|
|
@@ -742,9 +1037,12 @@ def register_file_commands(cli: Any) -> None:
|
|
|
742
1037
|
)
|
|
743
1038
|
|
|
744
1039
|
except requests_lib.HTTPError as exc:
|
|
745
|
-
if
|
|
1040
|
+
if _is_file_query_endpoint_unavailable(exc):
|
|
746
1041
|
_exit_search_files_required()
|
|
747
1042
|
handle_api_error(exc)
|
|
1043
|
+
except ValueError as exc:
|
|
1044
|
+
click.echo(f"✗ {exc}", err=True)
|
|
1045
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
748
1046
|
except Exception as exc:
|
|
749
1047
|
handle_api_error(exc)
|
|
750
1048
|
|
|
@@ -367,7 +367,10 @@ def info(format: str, skip_health: bool) -> None:
|
|
|
367
367
|
|
|
368
368
|
file_query_endpoint = platform_info.get("file_query_endpoint")
|
|
369
369
|
if file_query_endpoint:
|
|
370
|
-
if
|
|
370
|
+
if (
|
|
371
|
+
file_query_endpoint == "query-files-linq"
|
|
372
|
+
and platform_info.get("elasticsearch_available") is False
|
|
373
|
+
):
|
|
371
374
|
file_query_display = f"{file_query_endpoint} (Elasticsearch unavailable)"
|
|
372
375
|
else:
|
|
373
376
|
file_query_display = str(file_query_endpoint)
|
|
@@ -57,6 +57,7 @@ FEATURE_DISPLAY_NAMES: Dict[str, str] = {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
FILE_SEARCH_PATH = "/nifile/v1/service-groups/Default/search-files"
|
|
60
|
+
FILE_QUERY_PATH = "/nifile/v1/service-groups/Default/query-files"
|
|
60
61
|
FILE_QUERY_LINQ_PATH = "/nifile/v1/service-groups/Default/query-files-linq"
|
|
61
62
|
|
|
62
63
|
|
|
@@ -246,6 +247,39 @@ def get_file_query_capability(api_url: str, api_key: str) -> Dict[str, Any]:
|
|
|
246
247
|
"elasticsearch_available": None,
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
try:
|
|
251
|
+
query_resp = requests.post(
|
|
252
|
+
f"{api_url}{FILE_QUERY_PATH}",
|
|
253
|
+
headers=headers,
|
|
254
|
+
json={},
|
|
255
|
+
verify=ssl_verify,
|
|
256
|
+
timeout=10,
|
|
257
|
+
)
|
|
258
|
+
if query_resp.status_code in (200, 400):
|
|
259
|
+
return {
|
|
260
|
+
"status": "ok",
|
|
261
|
+
"file_query_endpoint": "query-files",
|
|
262
|
+
"elasticsearch_available": False,
|
|
263
|
+
}
|
|
264
|
+
if query_resp.status_code in (401, 403):
|
|
265
|
+
return {
|
|
266
|
+
"status": "unauthorized",
|
|
267
|
+
"file_query_endpoint": "query-files",
|
|
268
|
+
"elasticsearch_available": False,
|
|
269
|
+
}
|
|
270
|
+
if query_resp.status_code not in (404, 501):
|
|
271
|
+
return {
|
|
272
|
+
"status": "error" if query_resp.status_code >= 500 else "not_found",
|
|
273
|
+
"file_query_endpoint": None,
|
|
274
|
+
"elasticsearch_available": False,
|
|
275
|
+
}
|
|
276
|
+
except requests.RequestException:
|
|
277
|
+
return {
|
|
278
|
+
"status": "unreachable",
|
|
279
|
+
"file_query_endpoint": None,
|
|
280
|
+
"elasticsearch_available": False,
|
|
281
|
+
}
|
|
282
|
+
|
|
249
283
|
try:
|
|
250
284
|
fallback_resp = requests.post(
|
|
251
285
|
f"{api_url}{FILE_QUERY_LINQ_PATH}",
|
|
@@ -161,6 +161,9 @@ import { APP_BASE_HREF } from '@angular/common';
|
|
|
161
161
|
// Most Nimble component modules are exported from the main `@ni/nimble-angular` barrel.
|
|
162
162
|
// Icon modules (e.g. NimbleIconMagnifyingGlassModule) are ONLY in the main barrel —
|
|
163
163
|
// sub-paths like `@ni/nimble-angular/icons/magnifying-glass` do NOT exist.
|
|
164
|
+
// Do NOT add `@ni/nimble-components` or register raw custom elements just to make
|
|
165
|
+
// Angular templates compile. If a Nimble element is unknown, import the missing
|
|
166
|
+
// Angular module from `@ni/nimble-angular` instead.
|
|
164
167
|
import {
|
|
165
168
|
NimbleThemeProviderModule,
|
|
166
169
|
NimbleButtonModule,
|
|
@@ -214,8 +217,25 @@ import { MyFeatureComponent } from './my-feature/my-feature.component';
|
|
|
214
217
|
export class AppModule {}
|
|
215
218
|
```
|
|
216
219
|
|
|
220
|
+
When using `@ni/nimble-angular`, prefer the Angular wrappers end-to-end:
|
|
221
|
+
|
|
222
|
+
- Do **not** add `@ni/nimble-components` as a direct dependency just to register icons or other raw custom elements.
|
|
223
|
+
- Do **not** use `CUSTOM_ELEMENTS_SCHEMA` as a workaround for unknown Nimble elements. In this codebase that is usually a sign that the corresponding Nimble Angular module import is missing, and `CUSTOM_ELEMENTS_SCHEMA` suppresses valuable template checking.
|
|
224
|
+
- If Angular reports an unknown Nimble tag, fix the module imports first.
|
|
225
|
+
|
|
217
226
|
For Nimble form controls (`nimble-text-field`, `nimble-select`, etc.), bind with Angular forms APIs (`[(ngModel)]`, `[formControl]`, or `formControlName`) and use `(ngModelChange)` for value-change reactions. Avoid native control bindings like `[value]`, `(input)`, or `(change)` on Nimble elements.
|
|
218
227
|
|
|
228
|
+
For control labels, prefer Nimble's built-in label pattern by slotting text content inside the control instead of pairing the control with a separate HTML `<label>` element for the primary label:
|
|
229
|
+
|
|
230
|
+
```html
|
|
231
|
+
<nimble-select [(ngModel)]="selectedWorkspace">
|
|
232
|
+
Workspace
|
|
233
|
+
<nimble-list-option *ngFor="let ws of workspaces" [value]="ws.id">
|
|
234
|
+
{{ ws.name }}
|
|
235
|
+
</nimble-list-option>
|
|
236
|
+
</nimble-select>
|
|
237
|
+
```
|
|
238
|
+
|
|
219
239
|
**Critical:** Provide `APP_BASE_HREF` via DI and **remove the `<base href="/">` tag from `index.html`**. SystemLink enforces a `base-uri 'self'` CSP directive; the `<base>` element violates it.
|
|
220
240
|
|
|
221
241
|
---
|
|
@@ -737,6 +757,8 @@ Save the returned webapp ID — you'll need it for every subsequent redeploy.
|
|
|
737
757
|
| `InputFieldValidationError` on API call | SDK-generated request body has wrong shape | Inspect raw API; the generated type may add or omit a `request: {}` wrapper. Use direct `fetch` with manually constructed body |
|
|
738
758
|
| nimble-dialog does not open | `*ngIf` destroys element before `ViewChild` can resolve | Remove `*ngIf` from the dialog element; use `@ViewChild` + `ElementRef` and call `nativeElement.show()` / `nativeElement.close()` |
|
|
739
759
|
| Icon module import fails | Icon sub-path `@ni/nimble-angular/icons/...` does not exist | Import icon modules from the main `@ni/nimble-angular` barrel only |
|
|
760
|
+
| Angular says a Nimble tag is unknown | Missing `@ni/nimble-angular` module import | Import the missing wrapper module instead of adding `CUSTOM_ELEMENTS_SCHEMA` or registering raw `@ni/nimble-components` elements |
|
|
761
|
+
| Nimble form control label looks detached or duplicated | Used a separate HTML `<label>` for the primary control label | Slot the label text inside `nimble-text-field`, `nimble-select`, and similar controls |
|
|
740
762
|
| Table rows empty despite correct response | `projection` flattens nested objects | Remove `projection` from query body |
|
|
741
763
|
| `TableRecord` type error | Row type missing index signature | Add `[key: string]: FieldValue \| undefined` |
|
|
742
764
|
| Button appearance invalid | Wrong value for `appearance` attr | Use `appearance="block" appearance-variant="accent"` |
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Nimble Angular — Template & Usage Reference
|
|
2
2
|
|
|
3
|
+
## Wrapper-first rule
|
|
4
|
+
|
|
5
|
+
When building Angular apps with Nimble, use `@ni/nimble-angular` wrapper modules as the default integration path.
|
|
6
|
+
|
|
7
|
+
- Import the needed Angular module for each Nimble control instead of registering raw custom elements from `@ni/nimble-components`.
|
|
8
|
+
- Do not add `CUSTOM_ELEMENTS_SCHEMA` just to silence unknown Nimble elements in templates. That usually hides a missing module import and weakens Angular's template validation.
|
|
9
|
+
- If a Nimble icon or control is unknown, first look for the matching module in `@ni/nimble-angular` and import it.
|
|
10
|
+
|
|
3
11
|
## nimble-theme-provider
|
|
4
12
|
|
|
5
13
|
Wrap your entire app. Always place at the root component level.
|
|
@@ -176,6 +184,8 @@ import { NimbleTextFieldModule } from "@ni/nimble-angular";
|
|
|
176
184
|
</nimble-text-field>
|
|
177
185
|
```
|
|
178
186
|
|
|
187
|
+
Use the text inside the element as the control's primary label. Do not add a separate HTML `<label>` element for the basic Nimble control label unless you have a specific accessibility or layout need that cannot be expressed with Nimble's built-in labeling pattern.
|
|
188
|
+
|
|
179
189
|
Import icon modules from the **main `@ni/nimble-angular` barrel** — icon-specific sub-paths do not exist:
|
|
180
190
|
|
|
181
191
|
```typescript
|
|
@@ -195,6 +205,7 @@ import { NimbleSelectModule, NimbleListOptionModule } from "@ni/nimble-angular";
|
|
|
195
205
|
```html
|
|
196
206
|
<!-- Basic -->
|
|
197
207
|
<nimble-select [(ngModel)]="selectedType" (ngModelChange)="onTypeChange()">
|
|
208
|
+
Type
|
|
198
209
|
<nimble-list-option value="">All types</nimble-list-option>
|
|
199
210
|
<nimble-list-option value="DOUBLE">Double</nimble-list-option>
|
|
200
211
|
<nimble-list-option value="STRING">String</nimble-list-option>
|
|
@@ -203,12 +214,15 @@ import { NimbleSelectModule, NimbleListOptionModule } from "@ni/nimble-angular";
|
|
|
203
214
|
|
|
204
215
|
<!-- With built-in filter (useful for long lists) -->
|
|
205
216
|
<nimble-select filter-mode="standard" [(ngModel)]="selectedWorkspace">
|
|
217
|
+
Workspace
|
|
206
218
|
<nimble-list-option *ngFor="let ws of workspaces" [value]="ws.id"
|
|
207
219
|
>{{ ws.name }}</nimble-list-option
|
|
208
220
|
>
|
|
209
221
|
</nimble-select>
|
|
210
222
|
```
|
|
211
223
|
|
|
224
|
+
Use the slotted text content as the select label instead of pairing the control with a separate HTML `<label>` for the primary label.
|
|
225
|
+
|
|
212
226
|
> Use `filter-mode="standard"` to add a built-in text filter to the dropdown — no custom search logic needed for long option lists.
|
|
213
227
|
|
|
214
228
|
---
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-complete-workflow/README.md
RENAMED
|
File without changes
|
{systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-complete-workflow/config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-7-1-test-plans/README.md
RENAMED
|
File without changes
|
{systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-7-1-test-plans/config.yaml
RENAMED
|
File without changes
|
{systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/README.md
RENAMED
|
File without changes
|
{systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/slcli/references/analysis-recipes.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|