systemlink-cli 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,576 @@
1
+ """CLI commands for managing SystemLink workspaces."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any, Dict, Optional, Tuple
6
+
7
+ import click
8
+
9
+ from .cli_utils import validate_output_format
10
+ from .utils import (
11
+ ExitCodes,
12
+ format_success,
13
+ get_base_url,
14
+ handle_api_error,
15
+ make_api_request,
16
+ )
17
+
18
+
19
+ def _fetch_workspaces_page(
20
+ name_filter: Optional[str] = None, take: int = 25, skip: int = 0
21
+ ) -> Tuple[list, int, Optional[str]]:
22
+ """Fetch a single page of workspaces with optional server-side filtering.
23
+
24
+ Args:
25
+ name_filter: Optional filter pattern for workspace name (uses *TEXT* format
26
+ for case-insensitive substring matching)
27
+ take: Number of items to fetch (max 100)
28
+ skip: Number of items to skip
29
+
30
+ Returns:
31
+ Tuple of (workspaces_list, total_count, error_message).
32
+ Error message is None if successful.
33
+ """
34
+ try:
35
+ url = f"{get_base_url()}/niuser/v1/workspaces"
36
+ page_size = min(take, 100) # API max take is 100
37
+
38
+ query_params = [f"take={page_size}", f"skip={skip}"]
39
+ if name_filter:
40
+ # Use *TEXT* pattern for case-insensitive substring matching
41
+ query_params.append(f"name=*{name_filter}*")
42
+
43
+ paginated_url = url + "?" + "&".join(query_params)
44
+
45
+ resp = make_api_request("GET", paginated_url, payload=None)
46
+ data = resp.json()
47
+
48
+ workspaces = data.get("workspaces", [])
49
+ total_count = data.get("totalCount", 0)
50
+
51
+ return workspaces, total_count, None
52
+ except Exception as exc:
53
+ return [], 0, f"Failed to fetch workspaces: {str(exc)}"
54
+
55
+
56
+ def register_workspace_commands(cli: Any) -> None:
57
+ """Register the 'workspace' command group and its subcommands."""
58
+
59
+ @cli.group()
60
+ def workspace() -> None:
61
+ """Manage workspaces."""
62
+ pass
63
+
64
+ @workspace.command(name="list")
65
+ @click.option(
66
+ "--format",
67
+ "-f",
68
+ type=click.Choice(["table", "json"]),
69
+ default="table",
70
+ show_default=True,
71
+ help="Output format",
72
+ )
73
+ @click.option(
74
+ "--include-disabled",
75
+ is_flag=True,
76
+ help="Include disabled workspaces in the results",
77
+ )
78
+ @click.option(
79
+ "--filter",
80
+ "name_filter",
81
+ help="Filter by workspace name (case-insensitive substring match)",
82
+ )
83
+ @click.option(
84
+ "--take",
85
+ "-t",
86
+ type=int,
87
+ default=25,
88
+ show_default=True,
89
+ help="Maximum number of workspaces to return from API",
90
+ )
91
+ def list_workspaces(
92
+ format: str = "table",
93
+ include_disabled: bool = False,
94
+ name_filter: Optional[str] = None,
95
+ take: int = 25,
96
+ ) -> None:
97
+ """List workspaces with optional filtering.
98
+
99
+ The --filter option performs server-side case-insensitive substring
100
+ matching on workspace names. The --take option limits the number of
101
+ results shown per page (max 100).
102
+ """
103
+ format_output = validate_output_format(format)
104
+
105
+ try:
106
+ # For JSON format, respect --take and output without interactive pagination
107
+ if format_output.lower() == "json":
108
+ all_workspaces = []
109
+ skip = 0
110
+ remaining = take if take and take > 0 else 25
111
+ while remaining > 0:
112
+ page_take = min(remaining, 100)
113
+ workspaces, total_count, error = _fetch_workspaces_page(
114
+ name_filter, take=page_take, skip=skip
115
+ )
116
+ if error:
117
+ click.echo(f"✗ {error}", err=True)
118
+ sys.exit(ExitCodes.GENERAL_ERROR)
119
+
120
+ # Filter by enabled status if needed
121
+ if not include_disabled:
122
+ workspaces = [ws for ws in workspaces if ws.get("enabled", True)]
123
+
124
+ all_workspaces.extend(workspaces)
125
+
126
+ # Stop when we've collected the requested amount or reached total
127
+ if len(all_workspaces) >= take or skip + page_take >= total_count:
128
+ break
129
+
130
+ skip += page_take
131
+ remaining = take - len(all_workspaces)
132
+
133
+ # Trim in case we over-collected due to page boundaries
134
+ if take:
135
+ all_workspaces = all_workspaces[:take]
136
+ click.echo(json.dumps(all_workspaces, indent=2))
137
+ return
138
+
139
+ # For table format, implement interactive lazy loading
140
+ skip = 0
141
+ total_count_from_api = 0
142
+ shown_count = 0
143
+
144
+ def workspace_formatter(workspace: dict) -> list:
145
+ enabled = "✓" if workspace.get("enabled", True) else "✗"
146
+ default = "✓" if workspace.get("default", False) else ""
147
+ return [workspace.get("name", "Unknown"), workspace.get("id", ""), enabled, default]
148
+
149
+ while True:
150
+ # Fetch next page
151
+ workspaces, total_count_from_api, error = _fetch_workspaces_page(
152
+ name_filter, take=take, skip=skip
153
+ )
154
+
155
+ if error:
156
+ click.echo(f"✗ {error}", err=True)
157
+ sys.exit(ExitCodes.GENERAL_ERROR)
158
+
159
+ # Filter by enabled status if needed
160
+ if not include_disabled:
161
+ workspaces = [ws for ws in workspaces if ws.get("enabled", True)]
162
+
163
+ if not workspaces and skip == 0:
164
+ click.echo("No workspaces found.")
165
+ return
166
+
167
+ if not workspaces:
168
+ # No more results on this page
169
+ break
170
+
171
+ # Display the page
172
+ from .table_utils import output_formatted_list
173
+
174
+ output_formatted_list(
175
+ workspaces,
176
+ format_output,
177
+ ["Name", "ID", "Enabled", "Default"],
178
+ [30, 36, 8, 8],
179
+ workspace_formatter,
180
+ "", # Empty message not needed here
181
+ "workspace(s)",
182
+ )
183
+
184
+ shown_count += len(workspaces)
185
+ skip += take
186
+
187
+ # Check if there are potentially more results from the API
188
+ # We check skip against total_count to see if the next page exists
189
+ if skip >= total_count_from_api:
190
+ break
191
+
192
+ # Ask user if they want to see more; if non-interactive, stop
193
+ click.echo(f"\nShowing {shown_count} workspace(s) so far. More may be available.")
194
+
195
+ try:
196
+ is_tty = sys.stdout.isatty() and sys.stdin.isatty()
197
+ except Exception:
198
+ is_tty = False
199
+
200
+ if not is_tty:
201
+ break
202
+ if not click.confirm("Show next page?", default=True):
203
+ break
204
+
205
+ except Exception as exc:
206
+ handle_api_error(exc)
207
+
208
+ @workspace.command(name="disable")
209
+ @click.option(
210
+ "--id",
211
+ "-i",
212
+ required=True,
213
+ help="ID of the workspace to disable",
214
+ )
215
+ @click.confirmation_option(prompt="Are you sure you want to disable this workspace?")
216
+ def disable_workspace(id: str) -> None:
217
+ """Disable a workspace."""
218
+ from .utils import check_readonly_mode
219
+
220
+ check_readonly_mode("disable a workspace")
221
+
222
+ try:
223
+ # Get workspace info before disabling for confirmation
224
+ # Fetch all workspaces to find the target
225
+ all_workspaces = []
226
+ skip = 0
227
+ while True:
228
+ workspaces, total_count, error = _fetch_workspaces_page(take=100, skip=skip)
229
+ if error:
230
+ click.echo(f"✗ {error}", err=True)
231
+ sys.exit(ExitCodes.GENERAL_ERROR)
232
+
233
+ all_workspaces.extend(workspaces)
234
+
235
+ if skip + 100 >= total_count:
236
+ break
237
+ skip += 100
238
+
239
+ # Find the workspace to get its details
240
+ workspace_to_disable = None
241
+ for ws in all_workspaces:
242
+ if ws.get("id") == id:
243
+ workspace_to_disable = ws
244
+ break
245
+
246
+ if not workspace_to_disable:
247
+ click.echo(f"✗ Workspace with ID '{id}' not found", err=True)
248
+ sys.exit(ExitCodes.NOT_FOUND)
249
+
250
+ workspace_name = workspace_to_disable.get("name", id)
251
+
252
+ # Check if workspace is already disabled
253
+ if not workspace_to_disable.get("enabled", True):
254
+ click.echo(f"✗ Workspace '{workspace_name}' is already disabled", err=True)
255
+ sys.exit(ExitCodes.GENERAL_ERROR)
256
+
257
+ # Update the workspace to disable it
258
+ update_url = f"{get_base_url()}/niuser/v1/workspaces/{id}"
259
+ update_payload = {"name": workspace_name, "enabled": False}
260
+
261
+ make_api_request("PUT", update_url, update_payload)
262
+
263
+ format_success(
264
+ f"Workspace '{workspace_name}' disabled successfully",
265
+ {"id": id, "name": workspace_name, "enabled": False},
266
+ )
267
+
268
+ except Exception as exc:
269
+ handle_api_error(exc)
270
+
271
+ @workspace.command(name="get")
272
+ @click.option(
273
+ "--workspace",
274
+ "-w",
275
+ required=True,
276
+ help="Workspace name or ID",
277
+ )
278
+ @click.option(
279
+ "--format",
280
+ "-f",
281
+ type=click.Choice(["table", "json"]),
282
+ default="table",
283
+ show_default=True,
284
+ help="Output format",
285
+ )
286
+ def get_workspace(workspace: str, format: str) -> None:
287
+ """Show workspace details and contents."""
288
+ try:
289
+ # Get workspace info - fetch all pages until we find the workspace
290
+ all_workspaces = []
291
+ skip = 0
292
+ while True:
293
+ workspaces, total_count, error = _fetch_workspaces_page(take=100, skip=skip)
294
+ if error:
295
+ click.echo(f"✗ {error}", err=True)
296
+ sys.exit(ExitCodes.GENERAL_ERROR)
297
+
298
+ all_workspaces.extend(workspaces)
299
+
300
+ if skip + 100 >= total_count:
301
+ break
302
+ skip += 100
303
+
304
+ # Find the workspace by ID or name
305
+ target_workspace = None
306
+ target_workspace = next(
307
+ (
308
+ ws
309
+ for ws in all_workspaces
310
+ if ws.get("id") == workspace or ws.get("name") == workspace
311
+ ),
312
+ None,
313
+ )
314
+
315
+ if not target_workspace:
316
+ click.echo(f"✗ Workspace '{workspace}' not found", err=True)
317
+ sys.exit(ExitCodes.NOT_FOUND)
318
+
319
+ workspace_id = target_workspace.get("id")
320
+ workspace_name = target_workspace.get("name")
321
+
322
+ # Get workspace contents with error handling
323
+ templates, templates_error = _get_workspace_templates(workspace_id)
324
+ workflows, workflows_error = _get_workspace_workflows(workspace_id)
325
+ notebooks, notebooks_error = _get_workspace_notebooks(workspace_id)
326
+
327
+ # Prepare error information
328
+ access_errors = {}
329
+ if templates_error:
330
+ access_errors["templates"] = templates_error
331
+ if workflows_error:
332
+ access_errors["workflows"] = workflows_error
333
+ if notebooks_error:
334
+ access_errors["notebooks"] = notebooks_error
335
+
336
+ workspace_info = {
337
+ "workspace": {
338
+ "id": workspace_id,
339
+ "name": workspace_name,
340
+ "enabled": target_workspace.get("enabled", True),
341
+ "default": target_workspace.get("default", False),
342
+ },
343
+ "contents": {
344
+ "templates": templates,
345
+ "workflows": workflows,
346
+ "notebooks": notebooks,
347
+ },
348
+ "summary": {
349
+ "total_templates": len(templates),
350
+ "total_workflows": len(workflows),
351
+ "total_notebooks": len(notebooks),
352
+ },
353
+ }
354
+
355
+ # Add access errors to JSON output if any exist
356
+ if access_errors:
357
+ workspace_info["access_errors"] = access_errors
358
+
359
+ if format == "json":
360
+ click.echo(json.dumps(workspace_info, indent=2))
361
+ return
362
+
363
+ # Table format
364
+ click.echo(f"Workspace Information: {workspace_name}")
365
+ click.echo("=" * 50)
366
+ click.echo(f"ID: {workspace_id}")
367
+ click.echo(f"Name: {workspace_name}")
368
+ click.echo(f"Enabled: {'✓' if target_workspace.get('enabled', True) else '✗'}")
369
+ click.echo(f"Default: {'✓' if target_workspace.get('default', False) else '✗'}")
370
+
371
+ # Templates section
372
+ click.echo(f"\nTest Plan Templates ({len(templates)})")
373
+ click.echo("-" * 30)
374
+ if templates_error:
375
+ click.echo(f"✗ {templates_error}")
376
+ elif templates:
377
+ click.echo("┌" + "─" * 42 + "┬" + "─" * 38 + "┐")
378
+ click.echo(f"│ {'Name':<40} │ {'ID':<36} │")
379
+ click.echo("├" + "─" * 42 + "┼" + "─" * 38 + "┤")
380
+ for template in templates:
381
+ name = template.get("name", "")[:40]
382
+ template_id = template.get("id", "")[:36]
383
+ click.echo(f"│ {name:<40} │ {template_id:<36} │")
384
+ click.echo("└" + "─" * 42 + "┴" + "─" * 38 + "┘")
385
+ else:
386
+ click.echo("No test plan templates found.")
387
+
388
+ # Workflows section
389
+ click.echo(f"\nWorkflows ({len(workflows)})")
390
+ click.echo("-" * 30)
391
+ if workflows_error:
392
+ click.echo(f"✗ {workflows_error}")
393
+ elif workflows:
394
+ click.echo("┌" + "─" * 42 + "┬" + "─" * 38 + "┐")
395
+ click.echo(f"│ {'Name':<40} │ {'ID':<36} │")
396
+ click.echo("├" + "─" * 42 + "┼" + "─" * 38 + "┤")
397
+ for workflow in workflows:
398
+ name = workflow.get("name", "")[:40]
399
+ workflow_id = workflow.get("id", "")[:36]
400
+ click.echo(f"│ {name:<40} │ {workflow_id:<36} │")
401
+ click.echo("└" + "─" * 42 + "┴" + "─" * 38 + "┘")
402
+ else:
403
+ click.echo("No workflows found.")
404
+
405
+ # Notebooks section
406
+ click.echo(f"\nNotebooks ({len(notebooks)})")
407
+ click.echo("-" * 30)
408
+ if notebooks_error:
409
+ click.echo(f"✗ {notebooks_error}")
410
+ elif notebooks:
411
+ click.echo("┌" + "─" * 42 + "┬" + "─" * 38 + "┐")
412
+ click.echo(f"│ {'Name':<40} │ {'ID':<36} │")
413
+ click.echo("├" + "─" * 42 + "┼" + "─" * 38 + "┤")
414
+ for notebook in notebooks:
415
+ name = notebook.get("name", "")[:40]
416
+ notebook_id = notebook.get("id", "")[:36]
417
+ click.echo(f"│ {name:<40} │ {notebook_id:<36} │")
418
+ click.echo("└" + "─" * 42 + "┴" + "─" * 38 + "┘")
419
+ else:
420
+ click.echo("No notebooks found.")
421
+
422
+ except Exception as exc:
423
+ handle_api_error(exc)
424
+
425
+
426
+ def _get_workspace_map() -> Dict[str, str]:
427
+ """Get a mapping of workspace IDs to names.
428
+
429
+ Fetches all workspaces using pagination (max 100 per request).
430
+ """
431
+ try:
432
+ workspace_map: Dict[str, str] = {}
433
+ skip = 0
434
+ page_size = 100 # API max take is 100
435
+
436
+ while True:
437
+ url = f"{get_base_url()}/niuser/v1/workspaces?take={page_size}&skip={skip}"
438
+ resp = make_api_request("GET", url)
439
+ data = resp.json()
440
+ workspaces = data.get("workspaces", [])
441
+
442
+ # Add workspaces from this page to the map
443
+ for ws in workspaces:
444
+ if ws.get("id"):
445
+ workspace_map[ws.get("id")] = ws.get("name")
446
+
447
+ # Check if we got all workspaces
448
+ total_count = data.get("totalCount", 0)
449
+ if skip + len(workspaces) >= total_count:
450
+ break
451
+
452
+ skip += page_size
453
+
454
+ return workspace_map
455
+ except Exception:
456
+ return {}
457
+
458
+
459
+ def _get_workspace_templates(workspace_id: str) -> Tuple[list, Optional[str]]:
460
+ """Get test plan templates in a workspace using continuation token pagination.
461
+
462
+ Returns:
463
+ Tuple of (templates_list, error_message). If error_message is not None,
464
+ it indicates an access or permission issue.
465
+ """
466
+ try:
467
+ url = f"{get_base_url()}/niworkorder/v1/query-testplan-templates"
468
+ all_templates = []
469
+ continuation_token = None
470
+
471
+ while True:
472
+ payload = {
473
+ "take": 100, # Use smaller page size for efficient pagination
474
+ "projection": ["ID", "NAME"],
475
+ "filter": f'workspace == "{workspace_id}"',
476
+ }
477
+
478
+ if continuation_token:
479
+ payload["continuationToken"] = continuation_token
480
+
481
+ resp = make_api_request("POST", url, payload, handle_errors=False)
482
+ data = resp.json()
483
+
484
+ templates = data.get("testPlanTemplates", [])
485
+ all_templates.extend(templates)
486
+
487
+ # Check if there are more pages
488
+ continuation_token = data.get("continuationToken")
489
+ if not continuation_token:
490
+ break
491
+
492
+ return all_templates, None
493
+ except Exception as exc:
494
+ error_msg = str(exc).lower()
495
+ if "401" in error_msg or "unauthorized" in error_msg or "permission" in error_msg:
496
+ return [], "Access denied (insufficient permissions)"
497
+ elif "403" in error_msg or "forbidden" in error_msg:
498
+ return [], "Access forbidden"
499
+ elif "404" in error_msg or "not found" in error_msg:
500
+ return [], "Service not available"
501
+ else:
502
+ return [], f"Unable to retrieve templates: {str(exc)}"
503
+
504
+
505
+ def _get_workspace_workflows(workspace_id: str) -> Tuple[list, Optional[str]]:
506
+ """Get workflows in a workspace using continuation token pagination.
507
+
508
+ Returns:
509
+ Tuple of (workflows_list, error_message). If error_message is not None,
510
+ it indicates an access or permission issue.
511
+ """
512
+ try:
513
+ url = f"{get_base_url()}/niworkorder/v1/query-workflows?ff-userdefinedworkflowsfortestplaninstances=true"
514
+ all_workflows = []
515
+ continuation_token = None
516
+
517
+ while True:
518
+ payload = {
519
+ "take": 100, # Use smaller page size for efficient pagination
520
+ "projection": ["ID", "NAME", "WORKSPACE"],
521
+ }
522
+
523
+ if continuation_token:
524
+ payload["continuationToken"] = continuation_token
525
+
526
+ resp = make_api_request("POST", url, payload, handle_errors=False)
527
+ data = resp.json()
528
+
529
+ workflows = data.get("workflows", [])
530
+ all_workflows.extend(workflows)
531
+
532
+ # Check if there are more pages
533
+ continuation_token = data.get("continuationToken")
534
+ if not continuation_token:
535
+ break
536
+
537
+ # Filter workflows by workspace since the API doesn't support workspace filtering
538
+ workspace_workflows = [wf for wf in all_workflows if wf.get("workspace") == workspace_id]
539
+ return workspace_workflows, None
540
+ except Exception as exc:
541
+ error_msg = str(exc).lower()
542
+ if "401" in error_msg or "unauthorized" in error_msg or "permission" in error_msg:
543
+ return [], "Access denied (insufficient permissions)"
544
+ elif "403" in error_msg or "forbidden" in error_msg:
545
+ return [], "Access forbidden"
546
+ elif "404" in error_msg or "not found" in error_msg:
547
+ return [], "Service not available"
548
+ else:
549
+ return [], f"Unable to retrieve workflows: {str(exc)}"
550
+
551
+
552
+ def _get_workspace_notebooks(workspace_id: str) -> Tuple[list, Optional[str]]:
553
+ """Get notebooks in a workspace.
554
+
555
+ Returns:
556
+ Tuple of (notebooks_list, error_message). If error_message is not None,
557
+ it indicates an access or permission issue.
558
+ """
559
+ try:
560
+ url = f"{get_base_url()}/ninotebook/v1/notebook/query"
561
+ payload = {"take": 1000, "filter": f'workspace = "{workspace_id}"'}
562
+ resp = make_api_request("POST", url, payload, handle_errors=False)
563
+ data = resp.json()
564
+ notebooks = data.get("notebooks", [])
565
+ # Convert to consistent format
566
+ return [{"id": nb.get("id"), "name": nb.get("name")} for nb in notebooks], None
567
+ except Exception as exc:
568
+ error_msg = str(exc).lower()
569
+ if "401" in error_msg or "unauthorized" in error_msg or "permission" in error_msg:
570
+ return [], "Access denied (insufficient permissions)"
571
+ elif "403" in error_msg or "forbidden" in error_msg:
572
+ return [], "Access forbidden"
573
+ elif "404" in error_msg or "not found" in error_msg:
574
+ return [], "Service not available"
575
+ else:
576
+ return [], f"Unable to retrieve notebooks: {str(exc)}"