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.
Files changed (76) hide show
  1. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/PKG-INFO +1 -1
  2. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/pyproject.toml +1 -1
  3. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/_version.py +1 -1
  4. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/config_click.py +3 -1
  5. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/file_click.py +336 -38
  6. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/main.py +4 -1
  7. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/platform.py +34 -0
  8. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/SKILL.md +22 -0
  9. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/references/nimble-angular.md +14 -0
  10. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/LICENSE +0 -0
  11. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/dff-editor/editor.js +0 -0
  12. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/dff-editor/index.html +0 -0
  13. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/__init__.py +0 -0
  14. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/__main__.py +0 -0
  15. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/asset_click.py +0 -0
  16. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/cli_formatters.py +0 -0
  17. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/cli_utils.py +0 -0
  18. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/comment_click.py +0 -0
  19. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/completion_click.py +0 -0
  20. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/config.py +0 -0
  21. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/dff_click.py +0 -0
  22. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/dff_decorators.py +0 -0
  23. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/example_click.py +0 -0
  24. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/example_loader.py +0 -0
  25. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/example_provisioner.py +0 -0
  26. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/README.md +0 -0
  27. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/_schema/schema-v1.0.json +0 -0
  28. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-complete-workflow/README.md +0 -0
  29. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-complete-workflow/config.yaml +0 -0
  30. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-test-plans/README.md +0 -0
  31. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/demo-test-plans/config.yaml +0 -0
  32. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-5-1-parametric-insights/README.md +0 -0
  33. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-5-1-parametric-insights/config.yaml +0 -0
  34. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-7-1-test-plans/README.md +0 -0
  35. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/exercise-7-1-test-plans/config.yaml +0 -0
  36. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/README.md +0 -0
  37. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/config.yaml +0 -0
  38. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +0 -0
  39. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +0 -0
  40. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +0 -0
  41. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  42. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/feed_click.py +0 -0
  43. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/function_click.py +0 -0
  44. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/function_templates.py +0 -0
  45. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/mcp_click.py +0 -0
  46. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/mcp_server.py +0 -0
  47. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/notebook_click.py +0 -0
  48. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/policy_click.py +0 -0
  49. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/policy_utils.py +0 -0
  50. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/profiles.py +0 -0
  51. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/response_handlers.py +0 -0
  52. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/rich_output.py +0 -0
  53. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/routine_click.py +0 -0
  54. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skill_click.py +0 -0
  55. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/slcli/SKILL.md +0 -0
  56. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/slcli/references/analysis-recipes.md +0 -0
  57. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/slcli/references/filtering.md +0 -0
  58. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/references/deployment.md +0 -0
  59. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/references/layout-patterns.md +0 -0
  60. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/skills/systemlink-webapp/references/systemlink-services.md +0 -0
  61. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/ssl_trust.py +0 -0
  62. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/system_click.py +0 -0
  63. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/table_utils.py +0 -0
  64. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/tag_click.py +0 -0
  65. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/templates_click.py +0 -0
  66. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/testmonitor_click.py +0 -0
  67. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/universal_handlers.py +0 -0
  68. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/user_click.py +0 -0
  69. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/utils.py +0 -0
  70. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/web_editor.py +0 -0
  71. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/webapp_click.py +0 -0
  72. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workflow_preview.py +0 -0
  73. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workflows_click.py +0 -0
  74. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workitem_click.py +0 -0
  75. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workspace_click.py +0 -0
  76. {systemlink_cli-1.6.3 → systemlink_cli-1.6.5}/slcli/workspace_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: systemlink-cli
3
- Version: 1.6.3
3
+ Version: 1.6.5
4
4
  Summary: SystemLink Integrator CLI - cross-platform CLI for SystemLink workflows and templates.
5
5
  License-File: LICENSE
6
6
  Author: Fred Visser
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "systemlink-cli"
3
- version = "1.6.3"
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" }]
@@ -1,4 +1,4 @@
1
1
  """Version information for slcli."""
2
2
 
3
3
  # This file is auto-generated. Do not edit manually.
4
- __version__ = "1.6.3"
4
+ __version__ = "1.6.5"
@@ -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("elasticsearch_available") is False:
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 _is_search_files_unavailable(exc: requests_lib.HTTPError) -> bool:
39
- """Return whether the search-files endpoint is unavailable."""
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 the search-files endpoint, but Elasticsearch is not available.",
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 to query-files-linq.",
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 endpoint, fall back to query-files-linq if unavailable.
174
+ """Try search-files, then query-files, then query-files-linq.
63
175
 
64
- The search-files endpoint requires additional software (DataFinder) to be
65
- installed. If it is not available (HTTP 404), we fall back to query-files-linq.
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 _is_search_files_unavailable(exc):
82
- return _query_files_linq_fallback(
83
- take=payload.get("take", 25),
84
- workspace_id=workspace_id,
85
- name_filter=name_filter,
86
- id_filter=id_filter,
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-files-linq endpoint.
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
- url = f"{_get_file_service_url()}/query-files-linq"
232
- payload = {
233
- "filter": f'id = "{file_id}"',
234
- "take": 1,
235
- }
236
- resp = make_api_request("POST", url, payload=payload)
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": take if format_output.lower() == "json" else (take if take != 25 else 1000),
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 = make_api_request(
718
- "POST",
719
- _get_search_files_url(),
720
- payload=query_body,
721
- handle_errors=False,
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 _is_search_files_unavailable(exc):
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 platform_info.get("elasticsearch_available") is False:
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