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,1770 @@
1
+ """CLI commands for managing SystemLink notebooks via the SystemLink Notebook API.
2
+
3
+ Provides CLI commands for listing, creating, updating, downloading, and deleting Jupyter notebooks.
4
+ All commands use Click for robust CLI interfaces and error handling.
5
+ """
6
+
7
+ import datetime
8
+ import json
9
+ import sys
10
+ import time
11
+ import urllib.parse
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, cast
14
+
15
+ import click
16
+ import requests
17
+
18
+ from .cli_utils import validate_output_format
19
+ from .platform import PLATFORM_SLS, get_platform
20
+ from .universal_handlers import UniversalResponseHandler
21
+ from .utils import (
22
+ ExitCodes,
23
+ format_success,
24
+ make_api_request,
25
+ get_base_url,
26
+ get_headers,
27
+ get_ssl_verify,
28
+ get_workspace_id_with_fallback,
29
+ get_workspace_map,
30
+ handle_api_error,
31
+ save_json_file,
32
+ validate_workspace_access,
33
+ )
34
+ from .workspace_utils import get_effective_workspace, get_workspace_display_name
35
+
36
+
37
+ # Predefined notebook interfaces - must be assigned exactly as shown
38
+ PREDEFINED_NOTEBOOK_INTERFACES = [
39
+ "Assets Grid",
40
+ "Data Table Analysis",
41
+ "Data Space Analysis",
42
+ "File Analysis",
43
+ "Periodic Execution",
44
+ "Resource Changed Routine",
45
+ "Specification Analysis",
46
+ "Systems Grid",
47
+ "Test Data Analysis",
48
+ "Test Data Extraction",
49
+ "Work Item Automations",
50
+ "Work Item Operations",
51
+ "Work Item Scheduler",
52
+ ]
53
+
54
+
55
+ def _normalize_sls_notebook(notebook: Dict[str, Any]) -> None:
56
+ """Normalize SLS notebook response to include id/name fields.
57
+
58
+ SLS notebooks use 'path' as the identifier. This helper adds 'id' and 'name'
59
+ fields derived from 'path' to provide a consistent interface across platforms.
60
+
61
+ Args:
62
+ notebook: Notebook dictionary to normalize (modified in place).
63
+ """
64
+ if "path" in notebook:
65
+ if "id" not in notebook:
66
+ notebook["id"] = notebook["path"]
67
+ if "name" not in notebook:
68
+ path = notebook["path"]
69
+ notebook["name"] = path.split("/")[-1] if "/" in path else path
70
+
71
+
72
+ def _get_notebook_base_url() -> str:
73
+ """Get the base URL for notebook API.
74
+
75
+ Returns platform-specific URL:
76
+ - SLS (SystemLink Server): /ninbexec/v2 (notebooks by path)
77
+ - SLE (SystemLink Enterprise): /ninotebook/v1 (notebooks by ID)
78
+ """
79
+ if get_platform() == PLATFORM_SLS:
80
+ return f"{get_base_url()}/ninbexec/v2"
81
+ return f"{get_base_url()}/ninotebook/v1"
82
+
83
+
84
+ def _query_notebooks_http(
85
+ filter_str: Optional[str] = None, take: int = 1000
86
+ ) -> List[Dict[str, Any]]:
87
+ """Query notebooks using continuation token pagination for better performance."""
88
+ base_url = _get_notebook_base_url()
89
+ headers = get_headers("application/json")
90
+ is_sls = get_platform() == PLATFORM_SLS
91
+
92
+ all_notebooks = []
93
+ continuation_token = None
94
+
95
+ while True:
96
+ # Build payload for the request
97
+ payload: Dict[str, Any] = {"take": 100} # Use consistent page size for efficient pagination
98
+ if filter_str:
99
+ payload["filter"] = filter_str
100
+ if continuation_token:
101
+ payload["continuationToken"] = continuation_token
102
+
103
+ try:
104
+ # SLS uses /query-notebooks, SLE uses /notebook/query
105
+ if is_sls:
106
+ url = f"{base_url}/query-notebooks"
107
+ else:
108
+ url = f"{base_url}/notebook/query"
109
+
110
+ response = requests.post(url, headers=headers, json=payload, verify=get_ssl_verify())
111
+ response.raise_for_status()
112
+
113
+ data = response.json()
114
+ raw_notebooks = data.get("notebooks", []) # API returns "notebooks" array
115
+
116
+ # Handle invalid parameters gracefully and add notebooks to results
117
+ for nb in raw_notebooks:
118
+ try:
119
+ # Fix parameters field if it's a list instead of dict
120
+ if "parameters" in nb and isinstance(nb["parameters"], list):
121
+ nb["parameters"] = {
122
+ f"param_{i}": param for i, param in enumerate(nb["parameters"])
123
+ }
124
+ elif "parameters" not in nb or not isinstance(nb["parameters"], dict):
125
+ nb["parameters"] = {}
126
+
127
+ # For SLS, normalize to include id/name derived from path
128
+ if is_sls:
129
+ _normalize_sls_notebook(nb)
130
+
131
+ all_notebooks.append(nb)
132
+ except Exception:
133
+ # Skip notebooks with severe data issues
134
+ continue
135
+
136
+ # Check if there are more pages
137
+ continuation_token = data.get("continuationToken")
138
+ if not continuation_token:
139
+ break
140
+
141
+ except requests.exceptions.RequestException as exc:
142
+ raise Exception(f"HTTP request failed: {exc}")
143
+ except Exception as exc:
144
+ raise Exception(f"Failed to query notebooks: {exc}")
145
+
146
+ return all_notebooks
147
+
148
+
149
+ def _get_notebook_http(notebook_id: str) -> Dict[str, Any]:
150
+ """Get a single notebook by ID (SLE) or path (SLS) using HTTP."""
151
+ base_url = _get_notebook_base_url()
152
+ headers = get_headers("application/json")
153
+ is_sls = get_platform() == PLATFORM_SLS
154
+
155
+ try:
156
+ if is_sls:
157
+ # SLS uses path-based endpoint: /v2/notebooks/{path}
158
+ # The notebook_id is actually a path for SLS
159
+ # Path must be fully URL-encoded (with safe='' to encode all characters including /)
160
+ encoded_path = urllib.parse.quote(notebook_id, safe="")
161
+ url = f"{base_url}/notebooks/{encoded_path}"
162
+ else:
163
+ # SLE uses ID-based endpoint: /notebook/{id}
164
+ url = f"{base_url}/notebook/{notebook_id}"
165
+
166
+ response = requests.get(url, headers=headers, verify=get_ssl_verify())
167
+ response.raise_for_status()
168
+ result = response.json()
169
+
170
+ # For SLS, normalize the response to include id/name fields
171
+ if is_sls:
172
+ _normalize_sls_notebook(result)
173
+
174
+ return result
175
+ except requests.exceptions.RequestException as exc:
176
+ raise Exception(f"HTTP request failed: {exc}")
177
+
178
+
179
+ def _get_notebook_content_http(notebook_id: str) -> bytes:
180
+ """Get notebook content by ID (SLE) or path (SLS) using HTTP."""
181
+ base_url = _get_notebook_base_url()
182
+ headers = get_headers() # No content-type for binary content
183
+ is_sls = get_platform() == PLATFORM_SLS
184
+
185
+ try:
186
+ if is_sls:
187
+ # SLS uses path-based endpoint: /v2/notebooks/{path}/data
188
+ # Path must be fully URL-encoded (with safe='' to encode all characters including /)
189
+ encoded_path = urllib.parse.quote(notebook_id, safe="")
190
+ url = f"{base_url}/notebooks/{encoded_path}/data"
191
+ else:
192
+ # SLE uses ID-based endpoint: /notebook/{id}/content
193
+ url = f"{base_url}/notebook/{notebook_id}/content"
194
+
195
+ response = requests.get(url, headers=headers, verify=get_ssl_verify())
196
+ response.raise_for_status()
197
+ return response.content
198
+ except requests.exceptions.RequestException as exc:
199
+ raise Exception(f"HTTP request failed: {exc}")
200
+
201
+
202
+ # ------------------------------------------------------------------
203
+ # Notebook Execution Service helpers (module-scope for test patching)
204
+ # ------------------------------------------------------------------
205
+ def _get_notebook_execution_base() -> str:
206
+ """Get the base URL for notebook execution API.
207
+
208
+ Returns platform-specific URL:
209
+ - SLS (SystemLink Server): /ninbexec/v2
210
+ - SLE (SystemLink Enterprise): /ninbexecution/v1
211
+ """
212
+ if get_platform() == PLATFORM_SLS:
213
+ return f"{get_base_url()}/ninbexec/v2"
214
+ return f"{get_base_url()}/ninbexecution/v1"
215
+
216
+
217
+ def _query_notebook_executions(
218
+ workspace_id: Optional[str] = None,
219
+ status: Optional[str] = None,
220
+ notebook_id: Optional[str] = None,
221
+ notebook_path: Optional[str] = None,
222
+ ) -> List[Dict[str, Any]]:
223
+ """Query all notebook executions via POST /query-executions with pagination.
224
+
225
+ Args:
226
+ workspace_id: Optional workspace GUID filter (SLE only).
227
+ status: Optional already-mapped service status token (e.g. TIMEDOUT).
228
+ notebook_id: Optional notebook ID filter (SLE only).
229
+ notebook_path: Optional notebook path filter (SLS only).
230
+
231
+ Returns:
232
+ List of execution dictionaries.
233
+ """
234
+ base = _get_notebook_execution_base()
235
+ url = f"{base}/query-executions"
236
+ all_execs: List[Dict[str, Any]] = []
237
+ continuation_token: Optional[str] = None
238
+ is_sls = get_platform() == PLATFORM_SLS
239
+
240
+ def _escape_filter_value(value: str) -> str:
241
+ """Escape special characters in filter string values."""
242
+ # Escape backslashes and quotes to prevent filter injection
243
+ return value.replace("\\", "\\\\").replace('"', '\\"')
244
+
245
+ # Build filter based on platform
246
+ base_parts: List[str] = []
247
+ if is_sls:
248
+ # SLS uses notebookPath, no workspaceId
249
+ if notebook_path:
250
+ base_parts.append(f'notebookPath == "{_escape_filter_value(notebook_path)}"')
251
+ else:
252
+ # SLE uses workspaceId and notebookId
253
+ if workspace_id:
254
+ base_parts.append(f'workspaceId == "{_escape_filter_value(workspace_id)}"')
255
+ if notebook_id:
256
+ base_parts.append(f'notebookId == "{_escape_filter_value(notebook_id)}"')
257
+
258
+ while True:
259
+ payload: Dict[str, Any] = {
260
+ "take": 100,
261
+ "orderBy": "QUEUED_AT",
262
+ "descending": True,
263
+ }
264
+
265
+ # SLS does not support projection, only SLE does
266
+ if not is_sls:
267
+ # SLE uses notebookId and workspaceId projection
268
+ projection = (
269
+ "new(id, notebookId, workspaceId, userId, status, queuedAt, startedAt, "
270
+ "completedAt, source, resourceProfile, errorCode, lastUpdatedBy, retryCount)"
271
+ )
272
+ payload["projection"] = projection
273
+
274
+ filters_local = list(base_parts)
275
+ if status:
276
+ filters_local.append(f'status = "{status}"')
277
+ if filters_local:
278
+ payload["filter"] = " && ".join(filters_local)
279
+ if continuation_token:
280
+ payload["continuationToken"] = continuation_token
281
+ resp = make_api_request("POST", url, payload)
282
+ data = resp.json() if resp.text else {}
283
+ if isinstance(data, list): # pragma: no cover
284
+ executions = data # type: ignore[assignment]
285
+ continuation_token = None
286
+ else:
287
+ executions = data.get("executions", []) # type: ignore[assignment]
288
+ continuation_token = data.get("continuationToken")
289
+ all_execs.extend(executions)
290
+ if not continuation_token:
291
+ break
292
+ return all_execs
293
+
294
+
295
+ def _build_create_execution_payload(
296
+ notebook_id: str,
297
+ workspace: str,
298
+ timeout: int,
299
+ no_cache: bool,
300
+ parameters: Optional[str],
301
+ is_sls: bool,
302
+ ) -> Dict[str, Any]:
303
+ """Build the CreateExecution payload based on platform.
304
+
305
+ Args:
306
+ notebook_id: Notebook ID (SLE) or path (SLS).
307
+ workspace: Workspace name or ID (SLE only).
308
+ timeout: Execution timeout in seconds.
309
+ no_cache: Whether to disable result caching.
310
+ parameters: Optional JSON parameters string or @filepath.
311
+ is_sls: Whether the platform is SLS (SystemLink Server).
312
+
313
+ Returns:
314
+ Dictionary with the CreateExecution payload.
315
+
316
+ Raises:
317
+ SystemExit: If parameters JSON is invalid.
318
+ """
319
+ if is_sls:
320
+ # SLS uses notebookPath, no workspaceId
321
+ create_execution: Dict[str, Any] = {
322
+ "notebookPath": notebook_id,
323
+ "timeout": timeout,
324
+ }
325
+ else:
326
+ # SLE uses notebookId and workspaceId
327
+ workspace = get_effective_workspace(workspace) or workspace
328
+ ws_id = get_workspace_id_with_fallback(workspace)
329
+ create_execution = {
330
+ "workspaceId": ws_id,
331
+ "timeout": timeout,
332
+ "notebookId": notebook_id,
333
+ }
334
+
335
+ if no_cache:
336
+ create_execution["resultCachePeriod"] = 0
337
+ if parameters:
338
+ try:
339
+ if parameters.strip().startswith("@"):
340
+ p_file = parameters[1:]
341
+ with open(p_file, "r", encoding="utf-8") as pf:
342
+ create_execution["parameters"] = json.load(pf)
343
+ else:
344
+ create_execution["parameters"] = json.loads(parameters)
345
+ except Exception as exc: # noqa: BLE001
346
+ click.echo(f"✗ Invalid parameters JSON: {exc}", err=True)
347
+ sys.exit(ExitCodes.INVALID_INPUT)
348
+
349
+ return create_execution
350
+
351
+
352
+ def _parse_execution_response(resp_data: Any, is_sls: bool) -> List[Dict[str, Any]]:
353
+ """Parse the execution response based on platform.
354
+
355
+ Args:
356
+ resp_data: The JSON response data from the API.
357
+ is_sls: Whether the platform is SLS (SystemLink Server).
358
+
359
+ Returns:
360
+ List of execution dictionaries.
361
+ """
362
+ # SLS returns a list directly, SLE returns {"executions": [...]}
363
+ if is_sls:
364
+ return resp_data if isinstance(resp_data, list) else []
365
+ return cast(
366
+ List[Dict[str, Any]],
367
+ resp_data.get("executions") if isinstance(resp_data, dict) else [],
368
+ )
369
+
370
+
371
+ def _create_notebook_http(name: str, workspace: str, content: bytes) -> Dict[str, Any]:
372
+ """Create a notebook using HTTP. Only available on SLE."""
373
+ if get_platform() == PLATFORM_SLS:
374
+ raise Exception("Creating notebooks is not supported on SystemLink Server (SLS)")
375
+
376
+ base_url = _get_notebook_base_url()
377
+ headers = get_headers() # No content-type for multipart
378
+
379
+ # Create metadata following the SystemLink NotebookMetadata model structure
380
+ metadata = {
381
+ "name": name,
382
+ "workspace": workspace,
383
+ # Include optional fields that might be expected by the server
384
+ "properties": {},
385
+ "parameters": {},
386
+ }
387
+
388
+ # Validate content is valid JSON before sending
389
+ try:
390
+ json.loads(content.decode("utf-8"))
391
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
392
+ raise Exception(f"Invalid notebook content: {e}")
393
+
394
+ # Follow the official SystemLink client pattern: use BytesIO for metadata
395
+ metadata_json = json.dumps(metadata, separators=(",", ":")) # Compact JSON
396
+ metadata_bytes = metadata_json.encode("utf-8")
397
+
398
+ files = {
399
+ "metadata": ("metadata.json", metadata_bytes, "application/json"),
400
+ "content": ("notebook.ipynb", content, "application/octet-stream"),
401
+ }
402
+
403
+ try:
404
+ response = requests.post(
405
+ f"{base_url}/notebook", headers=headers, files=files, verify=get_ssl_verify()
406
+ )
407
+ response.raise_for_status()
408
+ return response.json()
409
+ except requests.exceptions.RequestException as exc:
410
+ # Enhanced error handling to capture server response details
411
+ error_details = f"HTTP request failed: {exc}"
412
+ if hasattr(exc, "response") and exc.response is not None:
413
+ try:
414
+ error_body = exc.response.text
415
+ error_details += f"\nResponse status: {exc.response.status_code}"
416
+ error_details += f"\nResponse body: {error_body}"
417
+
418
+ # Add request details for debugging
419
+ error_details += f"\nRequest URL: {exc.response.url}"
420
+ error_details += f"\nRequest metadata: {json.dumps(metadata)}"
421
+ error_details += f"\nContent size: {len(content)} bytes"
422
+ except Exception:
423
+ pass
424
+ raise Exception(error_details)
425
+
426
+
427
+ def _update_notebook_http(
428
+ notebook_id: str, metadata: Optional[Dict[str, Any]] = None, content: Optional[bytes] = None
429
+ ) -> Dict[str, Any]:
430
+ """Update a notebook using HTTP. Only available on SLE."""
431
+ if get_platform() == PLATFORM_SLS:
432
+ raise Exception("Updating notebooks is not supported on SystemLink Server (SLS)")
433
+
434
+ base_url = _get_notebook_base_url()
435
+ headers = get_headers() # No content-type for multipart
436
+
437
+ files = {}
438
+ if metadata:
439
+ # Use filename 'blob' to mirror working requests accepted by the service
440
+ files["metadata"] = ("blob", json.dumps(metadata), "application/json")
441
+ if content:
442
+ files["content"] = ("notebook.ipynb", content, "application/octet-stream") # type: ignore
443
+
444
+ try:
445
+ response = requests.put(
446
+ f"{base_url}/notebook/{notebook_id}",
447
+ headers=headers,
448
+ files=files,
449
+ verify=get_ssl_verify(),
450
+ )
451
+ response.raise_for_status()
452
+ return response.json()
453
+ except requests.exceptions.RequestException as exc:
454
+ raise Exception(f"HTTP request failed: {exc}")
455
+
456
+
457
+ def _delete_notebook_http(notebook_id: str) -> None:
458
+ """Delete a notebook using HTTP. Only available on SLE."""
459
+ if get_platform() == PLATFORM_SLS:
460
+ raise Exception("Deleting notebooks is not supported on SystemLink Server (SLS)")
461
+
462
+ base_url = _get_notebook_base_url()
463
+ headers = get_headers()
464
+
465
+ try:
466
+ response = requests.delete(
467
+ f"{base_url}/notebook/{notebook_id}", headers=headers, verify=get_ssl_verify()
468
+ )
469
+ response.raise_for_status()
470
+ except requests.exceptions.RequestException as exc:
471
+ raise Exception(f"HTTP request failed: {exc}")
472
+
473
+
474
+ def _validate_notebook_interface(interface: str) -> None:
475
+ """Validate that the interface string is one of the predefined interfaces.
476
+
477
+ Args:
478
+ interface: The interface string to validate.
479
+
480
+ Raises:
481
+ ValueError: If the interface is not in the predefined list.
482
+ """
483
+ if interface not in PREDEFINED_NOTEBOOK_INTERFACES:
484
+ valid = ", ".join(PREDEFINED_NOTEBOOK_INTERFACES)
485
+ raise ValueError(f"Invalid interface '{interface}'. Must be one of: {valid}")
486
+
487
+
488
+ def _set_notebook_interface_http(notebook_id: str, interface: str) -> Dict[str, Any]:
489
+ """Set the interface property on a notebook via HTTP PUT.
490
+
491
+ Args:
492
+ notebook_id: The notebook ID (SLE only).
493
+ interface: One of the predefined interface names.
494
+
495
+ Returns:
496
+ The updated notebook metadata.
497
+
498
+ Raises:
499
+ Exception: If the update fails or platform is SLS.
500
+ """
501
+ if get_platform() == PLATFORM_SLS:
502
+ raise Exception("Setting notebook interfaces is not supported on SystemLink Server (SLS)")
503
+
504
+ _validate_notebook_interface(interface)
505
+
506
+ # Fetch current notebook to get name and workspace
507
+ current_notebook = _get_notebook_http(notebook_id)
508
+
509
+ # Build metadata with all required fields
510
+ metadata = {
511
+ "name": current_notebook.get("name"),
512
+ "workspace": current_notebook.get("workspace"),
513
+ "properties": {"interface": interface},
514
+ }
515
+
516
+ return _update_notebook_http(notebook_id, metadata=metadata)
517
+
518
+
519
+ def _download_notebook_content_and_metadata(
520
+ notebook_id: str,
521
+ notebook_name: str,
522
+ output: Optional[str] = None,
523
+ download_type: str = "both",
524
+ ) -> None:
525
+ """Download notebook content and/or metadata to disk."""
526
+ content_failed = False
527
+ metadata_failed = False
528
+
529
+ # Download content
530
+ if download_type in ("content", "both"):
531
+ try:
532
+ content = _get_notebook_content_http(notebook_id)
533
+ output_path = output or (
534
+ notebook_name if notebook_name.endswith(".ipynb") else f"{notebook_name}.ipynb"
535
+ )
536
+ with open(output_path, "wb") as f:
537
+ f.write(content)
538
+ click.echo(f"Notebook content downloaded to {output_path}")
539
+ except Exception as exc:
540
+ click.echo(f"Failed to download notebook content: {exc}")
541
+ content_failed = True
542
+
543
+ # Download metadata
544
+ if download_type in ("metadata", "both"):
545
+ try:
546
+ meta = _get_notebook_http(notebook_id)
547
+ meta_path = (output or notebook_name.replace(".ipynb", "")) + ".json"
548
+
549
+ def _json_default(obj: Any) -> str:
550
+ if isinstance(obj, (datetime.datetime, datetime.date)):
551
+ return obj.isoformat()
552
+ return str(obj)
553
+
554
+ save_json_file(meta, meta_path, _json_default)
555
+ click.echo(f"Notebook metadata downloaded to {meta_path}")
556
+ except Exception as exc:
557
+ click.echo(f"Failed to download notebook metadata: {exc}")
558
+ metadata_failed = True
559
+
560
+ # If any download failed, raise an exception to trigger proper error handling
561
+ if content_failed and download_type in ("content", "both"):
562
+ raise Exception("Notebook content download failed")
563
+ if metadata_failed and download_type in ("metadata", "both"):
564
+ raise Exception("Notebook metadata download failed")
565
+
566
+
567
+ def register_notebook_commands(cli: Any) -> None:
568
+ """Register CLI commands for managing SystemLink notebooks.
569
+
570
+ Reorganized to mirror function commands structure:
571
+ - 'notebook init' for local scaffold
572
+ - 'notebook manage <subcommand>' for remote CRUD operations
573
+ - 'notebook execute <subcommand>' reserved for future execution features
574
+ """
575
+
576
+ @cli.group()
577
+ def notebook() -> None: # pragma: no cover - Click wiring
578
+ """Manage notebooks (init locally, manage remotely, run)."""
579
+ pass
580
+
581
+ # ------------------------------------------------------------------
582
+ # Local initialization (no remote API call)
583
+ # ------------------------------------------------------------------
584
+ @notebook.command(name="init")
585
+ @click.option(
586
+ "--name",
587
+ "notebook_name",
588
+ required=False,
589
+ default="new-notebook.ipynb",
590
+ show_default=True,
591
+ help="Notebook file name (will append .ipynb if missing)",
592
+ )
593
+ @click.option(
594
+ "--directory",
595
+ "directory",
596
+ type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
597
+ default=Path.cwd(),
598
+ show_default="CWD",
599
+ help="Target directory",
600
+ )
601
+ @click.option("--force", is_flag=True, help="Overwrite existing file if it exists")
602
+ def init_notebook(notebook_name: str, directory: Path, force: bool) -> None:
603
+ """Create a local Jupyter notebook skeleton."""
604
+ try:
605
+ if not notebook_name.lower().endswith(".ipynb"):
606
+ notebook_name += ".ipynb"
607
+ directory.mkdir(parents=True, exist_ok=True)
608
+ target = directory / notebook_name
609
+ if target.exists() and not force:
610
+ click.echo("✗ Target notebook already exists. Use --force to overwrite.", err=True)
611
+ sys.exit(ExitCodes.INVALID_INPUT)
612
+ empty_nb: Dict[str, Any] = {
613
+ "cells": [
614
+ {
615
+ "cell_type": "markdown",
616
+ "metadata": {},
617
+ "source": [
618
+ "# New Notebook\n",
619
+ "Created locally with slcli notebook init.\n",
620
+ ],
621
+ }
622
+ ],
623
+ "metadata": {
624
+ "kernelspec": {
625
+ "display_name": "Python 3 (ipykernel)",
626
+ "language": "python",
627
+ "name": "python3",
628
+ },
629
+ "language_info": {
630
+ "name": "python",
631
+ "version": sys.version.split()[0],
632
+ },
633
+ },
634
+ "nbformat": 4,
635
+ "nbformat_minor": 5,
636
+ }
637
+ with open(target, "w", encoding="utf-8") as f:
638
+ json.dump(empty_nb, f, indent=2)
639
+ format_success("Local notebook initialized", {"Path": str(target)})
640
+ except SystemExit:
641
+ raise
642
+ except Exception as exc: # noqa: BLE001
643
+ handle_api_error(exc)
644
+
645
+ # ------------------------------------------------------------------
646
+ # Subgroups
647
+ # ------------------------------------------------------------------
648
+ @notebook.group(name="manage")
649
+ def notebook_manage() -> None: # pragma: no cover - Click wiring
650
+ """Remote notebook CRUD operations."""
651
+ pass
652
+
653
+ @notebook.group(name="execute")
654
+ def notebook_execute() -> None: # pragma: no cover - Click wiring
655
+ """Notebook execution operations."""
656
+ pass
657
+
658
+ # ------------------------------------------------------------------
659
+ # Remote management commands (moved under 'manage')
660
+ # ------------------------------------------------------------------
661
+
662
+ # Backward compatibility: allow 'slcli notebook list' to still work by delegating.
663
+
664
+ @notebook_manage.command(name="update")
665
+ @click.option("--id", "-i", "notebook_id", required=True, help="Notebook ID to update")
666
+ @click.option(
667
+ "--metadata",
668
+ type=click.Path(exists=True, dir_okay=False),
669
+ required=False,
670
+ help="Path to JSON file containing notebook metadata (must match notebook schema)",
671
+ )
672
+ @click.option(
673
+ "--content",
674
+ type=click.Path(exists=True, dir_okay=False),
675
+ required=False,
676
+ help="Path to .ipynb file containing notebook content",
677
+ )
678
+ @click.option(
679
+ "--interface",
680
+ type=click.Choice(PREDEFINED_NOTEBOOK_INTERFACES, case_sensitive=True),
681
+ required=False,
682
+ help="Interface to assign to the notebook (optional)",
683
+ )
684
+ def update_notebook(
685
+ notebook_id: str,
686
+ metadata: Optional[str],
687
+ content: Optional[str],
688
+ interface: Optional[str] = None,
689
+ ) -> None:
690
+ """Update a notebook's metadata, content, interface, or any combination by ID.
691
+
692
+ Note: This command is only available on SystemLink Enterprise (SLE).
693
+ SystemLink Server (SLS) does not support notebook updates via API.
694
+ """
695
+ from .utils import check_readonly_mode
696
+
697
+ check_readonly_mode("update a notebook")
698
+
699
+ # Check if running on SLS - notebook updates not supported
700
+ if get_platform() == PLATFORM_SLS:
701
+ click.echo(
702
+ "✗ Updating notebooks is not supported on SystemLink Server (SLS). "
703
+ "Please use the SystemLink web interface to modify notebooks.",
704
+ err=True,
705
+ )
706
+ sys.exit(ExitCodes.INVALID_INPUT)
707
+
708
+ if not metadata and not content and not interface:
709
+ click.echo(
710
+ "✗ Must provide at least one of --metadata, --content, or --interface.",
711
+ err=True,
712
+ )
713
+ sys.exit(ExitCodes.INVALID_INPUT)
714
+
715
+ try:
716
+ meta_dict = None
717
+ if metadata:
718
+ with open(metadata, "r", encoding="utf-8") as f:
719
+ meta_dict = json.load(f)
720
+
721
+ content_bytes = None
722
+ if content:
723
+ with open(content, "rb") as f:
724
+ content_bytes = f.read()
725
+
726
+ # If interface is provided, validate and add it to the metadata
727
+ if interface:
728
+ _validate_notebook_interface(interface)
729
+ if not meta_dict:
730
+ meta_dict = {}
731
+ if "properties" not in meta_dict:
732
+ meta_dict["properties"] = {}
733
+ meta_dict["properties"]["interface"] = interface
734
+
735
+ if not meta_dict and not content_bytes:
736
+ click.echo(
737
+ "✗ Nothing to update. Provide --metadata, --content, and/or --interface.",
738
+ err=True,
739
+ )
740
+ sys.exit(ExitCodes.INVALID_INPUT)
741
+
742
+ _update_notebook_http(notebook_id, metadata=meta_dict, content=content_bytes)
743
+ format_success("Notebook updated", {"ID": notebook_id})
744
+ except Exception as exc:
745
+ handle_api_error(exc)
746
+
747
+ @notebook_manage.command(name="set-interface")
748
+ @click.option(
749
+ "--id",
750
+ "-i",
751
+ "notebook_id",
752
+ required=True,
753
+ help="Notebook ID to update",
754
+ )
755
+ @click.option(
756
+ "--interface",
757
+ "-f",
758
+ "interface_name",
759
+ required=True,
760
+ type=click.Choice(PREDEFINED_NOTEBOOK_INTERFACES, case_sensitive=True),
761
+ help="Interface to assign to the notebook",
762
+ )
763
+ def set_notebook_interface(notebook_id: str, interface_name: str) -> None:
764
+ """Assign an interface to a notebook.
765
+
766
+ The interface determines which SystemLink UI selectors will display
767
+ this notebook.
768
+
769
+ Note: This command is only available on SystemLink Enterprise (SLE).
770
+ SystemLink Server (SLS) does not support notebook interface assignment.
771
+ """
772
+ if get_platform() == PLATFORM_SLS:
773
+ click.echo(
774
+ "✗ Setting notebook interfaces is not supported on SystemLink Server (SLS). "
775
+ "Please use the SystemLink web interface to assign interfaces.",
776
+ err=True,
777
+ )
778
+ sys.exit(ExitCodes.INVALID_INPUT)
779
+
780
+ try:
781
+ result = _set_notebook_interface_http(notebook_id, interface_name)
782
+ assigned_interface = result.get("properties", {}).get("interface", interface_name)
783
+ format_success(
784
+ "Interface assigned",
785
+ {"Notebook ID": notebook_id, "Interface": assigned_interface},
786
+ )
787
+ except Exception as exc:
788
+ handle_api_error(exc)
789
+
790
+ @notebook_manage.command(name="list")
791
+ @click.option(
792
+ "--workspace",
793
+ "-w",
794
+ default="",
795
+ help="Filter by workspace name or ID",
796
+ )
797
+ @click.option(
798
+ "--filter",
799
+ "filter_text",
800
+ default="",
801
+ help="Case-insensitive substring to match name or interface",
802
+ )
803
+ @click.option(
804
+ "--take",
805
+ "-t",
806
+ type=int,
807
+ default=25,
808
+ show_default=True,
809
+ help="Maximum number of notebooks to return",
810
+ )
811
+ @click.option(
812
+ "--format",
813
+ "-f",
814
+ "format_output",
815
+ type=click.Choice(["table", "json"]),
816
+ default="table",
817
+ show_default=True,
818
+ help="Output format",
819
+ )
820
+ def list_notebooks(
821
+ workspace: str = "",
822
+ filter_text: str = "",
823
+ take: int = 25,
824
+ format_output: str = "table",
825
+ ) -> None:
826
+ """List notebooks."""
827
+ format_output = validate_output_format(format_output)
828
+
829
+ try:
830
+ ws_id = None
831
+ workspace = get_effective_workspace(workspace) or workspace
832
+ if workspace:
833
+ ws_id = validate_workspace_access(workspace, warn_on_error=True)
834
+
835
+ filter_parts: List[str] = []
836
+ if ws_id:
837
+ filter_parts.append(f'workspace = "{ws_id}"')
838
+ if filter_text:
839
+ # Build case-insensitive contains without ToLower() due to backend limitations.
840
+ # Match common variants: original, lower, upper, title-case.
841
+ # Apply case transformations first, then escape each variant.
842
+ def _esc(s: str) -> str:
843
+ return s.replace("\\", "\\\\").replace('"', '\\"')
844
+
845
+ original_raw = filter_text
846
+ lower_raw = original_raw.lower()
847
+ upper_raw = original_raw.upper()
848
+ title_raw = original_raw.title()
849
+ name_variants = [
850
+ f'name.Contains("{_esc(original_raw)}")',
851
+ f'name.Contains("{_esc(lower_raw)}")',
852
+ f'name.Contains("{_esc(upper_raw)}")',
853
+ f'name.Contains("{_esc(title_raw)}")',
854
+ ]
855
+ iface_variants = [
856
+ f'properties.interface.Contains("{_esc(original_raw)}")',
857
+ f'properties.interface.Contains("{_esc(lower_raw)}")',
858
+ f'properties.interface.Contains("{_esc(upper_raw)}")',
859
+ f'properties.interface.Contains("{_esc(title_raw)}")',
860
+ ]
861
+ filter_parts.append(
862
+ f"(({' or '.join(name_variants)}) or ({' or '.join(iface_variants)}))"
863
+ )
864
+
865
+ combined_filter = " and ".join(filter_parts) if filter_parts else None
866
+
867
+ try:
868
+ notebooks_raw = _query_notebooks_http(combined_filter, take=1000)
869
+ except Exception as exc:
870
+ click.echo(
871
+ f"✗ Error querying notebooks: {exc}",
872
+ err=True,
873
+ )
874
+ notebooks_raw = []
875
+
876
+ # Map workspace IDs to names for display
877
+ try:
878
+ workspace_map = get_workspace_map()
879
+ except Exception:
880
+ workspace_map = {}
881
+
882
+ notebooks = []
883
+ for idx, nb in enumerate(notebooks_raw):
884
+ try:
885
+ ws_id = nb.get("workspace", "")
886
+ ws_name = get_workspace_display_name(ws_id, workspace_map)
887
+ name = nb.get("name", "")
888
+ nb_id = nb.get("id", "")
889
+ parameters = nb.get("parameters", {})
890
+ interface = nb.get("properties", {}).get("interface")
891
+
892
+ notebook_data = {
893
+ "workspace": ws_name,
894
+ "name": name,
895
+ "id": nb_id,
896
+ "parameters": parameters,
897
+ }
898
+ if interface:
899
+ notebook_data["properties"] = {"interface": interface}
900
+ notebooks.append(notebook_data)
901
+ except Exception as nb_exc:
902
+ click.echo(
903
+ f"✗ Warning: Skipping invalid notebook result at index {idx}: {nb_exc}",
904
+ err=True,
905
+ )
906
+
907
+ if not notebooks:
908
+ if format_output.lower() == "json":
909
+ click.echo("[]")
910
+ else:
911
+ click.echo("No notebooks found.")
912
+ return
913
+
914
+ # Use universal response handler (create a mock response for consistency)
915
+ class MockResponse:
916
+ def json(self) -> Dict[str, Any]:
917
+ return {"notebooks": notebooks}
918
+
919
+ @property
920
+ def status_code(self) -> int:
921
+ return 200
922
+
923
+ mock_resp: Any = MockResponse() # Type annotation to avoid type checker issues
924
+
925
+ def notebook_formatter(notebook: dict) -> list:
926
+ interface = notebook.get("properties", {}).get("interface", "—")
927
+ return [
928
+ notebook.get("name", "Unknown"),
929
+ notebook.get("workspace", "N/A"),
930
+ interface,
931
+ notebook.get("id", ""),
932
+ "Jupyter", # Type
933
+ ]
934
+
935
+ UniversalResponseHandler.handle_list_response(
936
+ resp=mock_resp,
937
+ data_key="notebooks",
938
+ item_name="notebook",
939
+ format_output=format_output,
940
+ formatter_func=notebook_formatter,
941
+ headers=["Name", "Workspace", "Interface", "ID", "Type"],
942
+ column_widths=[30, 20, 25, 36, 12],
943
+ empty_message="No notebooks found.",
944
+ enable_pagination=True,
945
+ page_size=take,
946
+ )
947
+
948
+ except Exception as exc:
949
+ handle_api_error(exc)
950
+
951
+ @notebook_manage.command(name="download")
952
+ @click.option("--id", "-i", "notebook_id", help="Notebook ID")
953
+ @click.option("--name", "notebook_name", help="Notebook name")
954
+ @click.option("--workspace", default="Default", help="Workspace name or ID (default: Default)")
955
+ @click.option("--output", required=False, help="Output file path (defaults to notebook name)")
956
+ @click.option(
957
+ "--type",
958
+ "download_type",
959
+ type=click.Choice(["content", "metadata", "both"], case_sensitive=False),
960
+ default="content",
961
+ show_default=True,
962
+ help="What to download: notebook content, metadata, or both",
963
+ )
964
+ def download_notebook(
965
+ notebook_id: str = "",
966
+ notebook_name: str = "",
967
+ workspace: str = "Default",
968
+ output: str = "",
969
+ download_type: str = "content",
970
+ ) -> None:
971
+ """Download notebook content/metadata."""
972
+ if not notebook_id and not notebook_name:
973
+ click.echo("✗ Must provide either --id or --name.", err=True)
974
+ sys.exit(ExitCodes.INVALID_INPUT)
975
+
976
+ ws_id = get_workspace_id_with_fallback(get_effective_workspace(workspace) or workspace)
977
+
978
+ try:
979
+ nb_name = notebook_name
980
+ # Find notebook by name or id
981
+ if notebook_name:
982
+ filter_str = f'name = "{notebook_name}" and workspace = "{ws_id}"'
983
+ results = _query_notebooks_http(filter_str)
984
+ found = next(
985
+ (nb for nb in results if nb.get("name") == notebook_name),
986
+ None,
987
+ )
988
+ if not found:
989
+ click.echo(f"✗ Notebook named '{notebook_name}' not found.", err=True)
990
+ sys.exit(ExitCodes.NOT_FOUND)
991
+ notebook_id = found.get("id", "")
992
+ nb_name = found.get("name", notebook_name)
993
+ elif notebook_id:
994
+ if not output:
995
+ filter_str = f'id = "{notebook_id}" and workspace = "{ws_id}"'
996
+ results = _query_notebooks_http(filter_str)
997
+ nb_name = results[0].get("name", notebook_id) if results else notebook_id
998
+
999
+ # Download notebook content and/or metadata using shared helper
1000
+ if not isinstance(notebook_id, str) or not notebook_id:
1001
+ click.echo("✗ Notebook ID must be a non-empty string.", err=True)
1002
+ sys.exit(ExitCodes.INVALID_INPUT)
1003
+ _download_notebook_content_and_metadata(
1004
+ notebook_id,
1005
+ nb_name,
1006
+ output=output,
1007
+ download_type=download_type,
1008
+ )
1009
+ click.echo(f"✓ Notebook ID: {notebook_id}")
1010
+ except Exception as exc:
1011
+ handle_api_error(exc)
1012
+
1013
+ @notebook_manage.command(name="get")
1014
+ @click.option(
1015
+ "--id",
1016
+ "-i",
1017
+ "notebook_id",
1018
+ required=True,
1019
+ help="Notebook ID (SLE) or path (SLS) to retrieve",
1020
+ )
1021
+ @click.option(
1022
+ "--format",
1023
+ "-f",
1024
+ type=click.Choice(["table", "json"]),
1025
+ default="table",
1026
+ show_default=True,
1027
+ help="Output format",
1028
+ )
1029
+ def get_notebook(notebook_id: str, format: str = "table") -> None: # type: ignore[override]
1030
+ """Show notebook details.
1031
+
1032
+ Displays notebook metadata and basic properties. For content download use
1033
+ 'slcli notebook manage download'.
1034
+
1035
+ On SLS, use the notebook path (e.g., '_shared/reports/First Pass Yield.ipynb').
1036
+ On SLE, use the notebook ID (GUID).
1037
+ """
1038
+ format_output = validate_output_format(format)
1039
+ is_sls = get_platform() == PLATFORM_SLS
1040
+
1041
+ try:
1042
+ if is_sls:
1043
+ # SLS: Use direct GET endpoint with path
1044
+ notebook = _get_notebook_http(notebook_id)
1045
+ else:
1046
+ # SLE: Use query filter to fetch notebook by ID
1047
+ filter_str = f'id = "{notebook_id}"'
1048
+ results = _query_notebooks_http(filter_str)
1049
+ if not results:
1050
+ click.echo("✗ Notebook not found.", err=True)
1051
+ sys.exit(ExitCodes.NOT_FOUND)
1052
+ notebook = results[0]
1053
+
1054
+ if format_output == "json":
1055
+ click.echo(json.dumps(notebook, indent=2))
1056
+ return
1057
+
1058
+ workspace_map = get_workspace_map()
1059
+ ws_name = get_workspace_display_name(
1060
+ notebook.get("workspace", ""),
1061
+ workspace_map,
1062
+ )
1063
+
1064
+ click.echo("Notebook Details:")
1065
+ click.echo("=" * 50)
1066
+ click.echo(f"ID: {notebook.get('id', 'N/A')}")
1067
+ click.echo(f"Name: {notebook.get('name', 'N/A')}")
1068
+ if is_sls:
1069
+ click.echo(f"Path: {notebook.get('path', 'N/A')}")
1070
+ click.echo(f"Workspace: {ws_name}")
1071
+ interface = notebook.get("properties", {}).get("interface")
1072
+ if interface:
1073
+ click.echo(f"Interface: {interface}")
1074
+ click.echo(f"Size (bytes): {notebook.get('size', 'N/A')}")
1075
+ click.echo(f"Created At: {notebook.get('createdAt', 'N/A')}")
1076
+ click.echo(f"Updated At: {notebook.get('updatedAt', 'N/A')}")
1077
+ # Additional metadata keys if present
1078
+ kernel_spec = notebook.get("kernel") or notebook.get("kernelspec")
1079
+ if kernel_spec:
1080
+ if isinstance(kernel_spec, dict):
1081
+ click.echo(f"Kernel: {kernel_spec.get('name', 'python')}")
1082
+ else:
1083
+ click.echo(f"Kernel: {kernel_spec}")
1084
+ except Exception as exc:
1085
+ handle_api_error(exc)
1086
+
1087
+ @notebook_manage.command(name="create")
1088
+ @click.option("--file", "input_file", required=False, help="Path to notebook file to create")
1089
+ @click.option("--workspace", default="Default", help="Workspace name or ID (default: Default)")
1090
+ @click.option("--name", "notebook_name", required=True, help="Notebook name")
1091
+ @click.option(
1092
+ "--interface",
1093
+ type=click.Choice(PREDEFINED_NOTEBOOK_INTERFACES, case_sensitive=True),
1094
+ required=False,
1095
+ help="Interface to assign to the notebook (optional)",
1096
+ )
1097
+ def create_notebook(
1098
+ input_file: str = "",
1099
+ workspace: str = "Default",
1100
+ notebook_name: str = "",
1101
+ interface: Optional[str] = None,
1102
+ ) -> None:
1103
+ """Create a notebook with optional interface assignment.
1104
+
1105
+ Fails if a notebook with the same name exists.
1106
+
1107
+ Note: This command is only available on SystemLink Enterprise (SLE).
1108
+ SystemLink Server (SLS) does not support notebook creation via API.
1109
+ """
1110
+ from .utils import check_readonly_mode
1111
+
1112
+ check_readonly_mode("create a notebook")
1113
+
1114
+ # Validate interface early if provided
1115
+ if interface:
1116
+ try:
1117
+ _validate_notebook_interface(interface)
1118
+ except ValueError as exc:
1119
+ click.echo(f"✗ {exc}", err=True)
1120
+ sys.exit(ExitCodes.INVALID_INPUT)
1121
+
1122
+ # Check if running on SLS - notebook creation not supported
1123
+ if get_platform() == PLATFORM_SLS:
1124
+ click.echo(
1125
+ "✗ Creating notebooks is not supported on SystemLink Server (SLS). "
1126
+ "Please use the SystemLink web interface to upload notebooks.",
1127
+ err=True,
1128
+ )
1129
+ sys.exit(ExitCodes.INVALID_INPUT)
1130
+
1131
+ workspace = get_effective_workspace(workspace) or workspace
1132
+ ws_id = get_workspace_id_with_fallback(workspace)
1133
+
1134
+ try:
1135
+ # Validate workspace exists and is accessible
1136
+ try:
1137
+ workspace_map = get_workspace_map()
1138
+ if ws_id not in workspace_map and workspace != ws_id:
1139
+ # If ws_id is not in workspace_map and we didn't get it directly from user
1140
+ click.echo(
1141
+ f"⚠️ Warning: Workspace '{workspace}' may not exist or be accessible.",
1142
+ err=True,
1143
+ )
1144
+ except Exception:
1145
+ click.echo("⚠️ Warning: Could not validate workspace access.", err=True)
1146
+
1147
+ # Ensure the uploaded file has a .ipynb extension
1148
+ if not notebook_name.lower().endswith(".ipynb"):
1149
+ notebook_name += ".ipynb"
1150
+ # Check for existing notebook with same name in workspace
1151
+ filter_str = f'name = "{notebook_name}" and workspace = "{ws_id}"'
1152
+ results = _query_notebooks_http(filter_str)
1153
+ if results:
1154
+ click.echo(
1155
+ f"✗ A notebook named '{notebook_name}' already exists in this workspace. Creation cancelled.",
1156
+ err=True,
1157
+ )
1158
+ sys.exit(ExitCodes.INVALID_INPUT)
1159
+
1160
+ # No existing notebook, create new
1161
+ if input_file:
1162
+ with open(input_file, "rb") as content_file:
1163
+ content = content_file.read()
1164
+ result = _create_notebook_http(notebook_name, ws_id, content)
1165
+ format_success("Notebook created", {"ID": result.get("id")})
1166
+ else:
1167
+ # Create a notebook matching the structure of successful SystemLink notebooks
1168
+ empty_nb = {
1169
+ "cells": [
1170
+ {
1171
+ "cell_type": "markdown",
1172
+ "id": "new-notebook-cell",
1173
+ "metadata": {},
1174
+ "source": "# New Notebook\n\nThis is a new notebook created with SystemLink CLI.",
1175
+ }
1176
+ ],
1177
+ "metadata": {
1178
+ "kernelspec": {
1179
+ "display_name": "Python 3 (ipykernel)",
1180
+ "language": "python",
1181
+ "name": "python3",
1182
+ },
1183
+ "language_info": {
1184
+ "codemirror_mode": {"name": "ipython", "version": 3},
1185
+ "file_extension": ".py",
1186
+ "mimetype": "text/x-python",
1187
+ "name": "python",
1188
+ "nbconvert_exporter": "python",
1189
+ "pygments_lexer": "ipython3",
1190
+ "version": "3.11.6",
1191
+ },
1192
+ "toc-showtags": False,
1193
+ },
1194
+ "nbformat": 4,
1195
+ "nbformat_minor": 5,
1196
+ }
1197
+
1198
+ content = json.dumps(empty_nb, indent=2).encode("utf-8")
1199
+ result = _create_notebook_http(notebook_name, ws_id, content)
1200
+ format_success("Notebook created", {"ID": result.get("id")})
1201
+
1202
+ # Validate and assign interface if provided (requires separate call after creation)
1203
+ if interface and result:
1204
+ try:
1205
+ notebook_id = result.get("id", "")
1206
+ if notebook_id:
1207
+ _set_notebook_interface_http(notebook_id, interface)
1208
+ click.echo(f"✓ Interface '{interface}' assigned")
1209
+ except Exception as exc:
1210
+ click.echo(f"⚠ Warning: Failed to assign interface: {exc}", err=True)
1211
+
1212
+ # Download option removed: users should run 'notebook manage download' after creation
1213
+ # if they want to retrieve content or metadata.
1214
+ except Exception as exc:
1215
+ handle_api_error(exc)
1216
+
1217
+ @notebook_manage.command(name="delete")
1218
+ @click.option("--id", "-i", "notebook_id", required=True, help="Notebook ID to delete")
1219
+ @click.confirmation_option(prompt="Are you sure you want to delete this notebook?")
1220
+ def delete_notebook(notebook_id: str = "") -> None:
1221
+ """Delete a notebook.
1222
+
1223
+ Note: This command is only available on SystemLink Enterprise (SLE).
1224
+ SystemLink Server (SLS) does not support notebook deletion via API.
1225
+ """
1226
+ from .utils import check_readonly_mode
1227
+
1228
+ check_readonly_mode("delete a notebook")
1229
+
1230
+ # Check if running on SLS - notebook deletion not supported
1231
+ if get_platform() == PLATFORM_SLS:
1232
+ click.echo(
1233
+ "✗ Deleting notebooks is not supported on SystemLink Server (SLS). "
1234
+ "Please use the SystemLink web interface to delete notebooks.",
1235
+ err=True,
1236
+ )
1237
+ sys.exit(ExitCodes.INVALID_INPUT)
1238
+
1239
+ try:
1240
+ _delete_notebook_http(notebook_id)
1241
+ format_success("Notebook deleted", {"ID": notebook_id})
1242
+ except Exception as exc:
1243
+ handle_api_error(exc)
1244
+
1245
+ # ------------------------------------------------------------------
1246
+ # Execution helpers (Notebook Execution Service)
1247
+ # ------------------------------------------------------------------
1248
+
1249
+ # (Execution helpers now defined at module scope for test patching.)
1250
+
1251
+ # ------------------------------------------------------------------
1252
+ # Execution commands (mirror function execute group)
1253
+ # ------------------------------------------------------------------
1254
+
1255
+ @notebook_execute.command(name="list")
1256
+ @click.option("--workspace", "-w", help="Filter by workspace name or ID")
1257
+ @click.option(
1258
+ "--status",
1259
+ "-s",
1260
+ help=(
1261
+ "Filter by execution status (case-insensitive). Allowed: in_progress, queued, failed, "
1262
+ "succeeded, canceled, timed_out."
1263
+ ),
1264
+ )
1265
+ @click.option("--notebook-id", "-n", help="Filter by notebook ID")
1266
+ @click.option("--take", "-t", type=int, default=25, show_default=True, help="Max rows/page")
1267
+ @click.option(
1268
+ "--format",
1269
+ "-f",
1270
+ type=click.Choice(["table", "json"]),
1271
+ default="table",
1272
+ show_default=True,
1273
+ help="Output format",
1274
+ )
1275
+ def list_notebook_executions(
1276
+ workspace: Optional[str] = None,
1277
+ status: Optional[str] = None,
1278
+ notebook_id: Optional[str] = None,
1279
+ take: int = 25,
1280
+ format: str = "table",
1281
+ ) -> None:
1282
+ """List notebook executions."""
1283
+ format_output = validate_output_format(format)
1284
+ try:
1285
+ workspace_map = get_workspace_map()
1286
+ workspace_guid = None
1287
+ workspace = get_effective_workspace(workspace)
1288
+ if workspace:
1289
+ workspace_guid = get_workspace_id_with_fallback(workspace)
1290
+
1291
+ def _normalize_execution_status(raw: str) -> str:
1292
+ """Normalize user-provided execution status to service token.
1293
+
1294
+ Accept (case-insensitive) one of:
1295
+ in_progress, queued, failed, succeeded, canceled, timed_out
1296
+
1297
+ Map to service forms (no underscore for IN_PROGRESS/TIMED_OUT):
1298
+ IN_PROGRESS -> INPROGRESS
1299
+ TIMED_OUT -> TIMEDOUT
1300
+ Others pass through unchanged uppercased.
1301
+ """
1302
+ canonical = raw.strip().upper().replace("-", "_")
1303
+ mapping = {
1304
+ "IN_PROGRESS": "INPROGRESS",
1305
+ "TIMED_OUT": "TIMEDOUT",
1306
+ "QUEUED": "QUEUED",
1307
+ "FAILED": "FAILED",
1308
+ "SUCCEEDED": "SUCCEEDED",
1309
+ "CANCELED": "CANCELED",
1310
+ }
1311
+ if canonical in mapping:
1312
+ return mapping[canonical]
1313
+ click.echo(
1314
+ "✗ Invalid status. Allowed: in_progress, queued, failed, succeeded, canceled, timed_out",
1315
+ err=True,
1316
+ )
1317
+ sys.exit(ExitCodes.INVALID_INPUT)
1318
+
1319
+ status_service_value = _normalize_execution_status(status) if status else None
1320
+
1321
+ is_sls = get_platform() == PLATFORM_SLS
1322
+ if is_sls:
1323
+ # SLS doesn't support workspaceId filtering, uses notebookPath
1324
+ executions = _query_notebook_executions(
1325
+ status=status_service_value, notebook_path=notebook_id
1326
+ )
1327
+ else:
1328
+ # SLE uses workspaceId and notebookId
1329
+ executions = _query_notebook_executions(
1330
+ workspace_guid, status_service_value, notebook_id
1331
+ )
1332
+
1333
+ class ExecResp:
1334
+ def json(self) -> Dict[str, Any]: # noqa: D401
1335
+ return {"executions": executions}
1336
+
1337
+ @property
1338
+ def status_code(self) -> int:
1339
+ return 200
1340
+
1341
+ mock_resp: Any = ExecResp()
1342
+
1343
+ def exec_formatter(exe: Dict[str, Any]) -> List[str]:
1344
+ queued_at = exe.get("queuedAt", "")
1345
+ if queued_at:
1346
+ queued_at = queued_at.split("T")[0]
1347
+ if is_sls:
1348
+ # SLS uses notebookPath
1349
+ return [
1350
+ exe.get("id", ""),
1351
+ exe.get("notebookPath", ""),
1352
+ exe.get("status", "UNKNOWN"),
1353
+ queued_at,
1354
+ ]
1355
+ else:
1356
+ # SLE uses notebookId and workspaceId
1357
+ ws_name = get_workspace_display_name(exe.get("workspaceId", ""), workspace_map)
1358
+ return [
1359
+ exe.get("id", ""),
1360
+ exe.get("notebookId", ""),
1361
+ ws_name,
1362
+ exe.get("status", "UNKNOWN"),
1363
+ queued_at,
1364
+ ]
1365
+
1366
+ if is_sls:
1367
+ headers = ["ID", "Notebook Path", "Status", "Queued"]
1368
+ column_widths = [36, 50, 12, 12]
1369
+ else:
1370
+ headers = ["ID", "Notebook ID", "Workspace", "Status", "Queued"]
1371
+ column_widths = [36, 36, 20, 12, 12]
1372
+
1373
+ UniversalResponseHandler.handle_list_response(
1374
+ resp=mock_resp,
1375
+ data_key="executions",
1376
+ item_name="execution",
1377
+ format_output=format_output,
1378
+ formatter_func=exec_formatter,
1379
+ headers=headers,
1380
+ column_widths=column_widths,
1381
+ empty_message="No notebook executions found.",
1382
+ enable_pagination=True,
1383
+ page_size=take,
1384
+ )
1385
+ except Exception as exc: # noqa: BLE001
1386
+ handle_api_error(exc)
1387
+
1388
+ @notebook_execute.command(name="get")
1389
+ @click.option("--id", "-i", "execution_id", required=True, help="Execution ID")
1390
+ @click.option(
1391
+ "--format",
1392
+ "-f",
1393
+ type=click.Choice(["table", "json"]),
1394
+ default="table",
1395
+ show_default=True,
1396
+ help="Output format",
1397
+ )
1398
+ def get_notebook_execution(execution_id: str, format: str = "table") -> None:
1399
+ """Show a notebook execution."""
1400
+ format_output = validate_output_format(format)
1401
+ base = _get_notebook_execution_base()
1402
+ url = f"{base}/executions/{execution_id}"
1403
+ try:
1404
+ data = make_api_request("GET", url).json()
1405
+ if format_output == "json":
1406
+ click.echo(json.dumps(data, indent=2))
1407
+ return
1408
+
1409
+ is_sls = get_platform() == PLATFORM_SLS
1410
+ click.echo("Notebook Execution Details:")
1411
+ click.echo("=" * 50)
1412
+ click.echo(f"ID: {data.get('id', 'N/A')}")
1413
+
1414
+ if is_sls:
1415
+ # SLS uses notebookPath
1416
+ click.echo(f"Notebook Path: {data.get('notebookPath', 'N/A')}")
1417
+ else:
1418
+ # SLE uses notebookId and workspaceId
1419
+ workspace_map = get_workspace_map()
1420
+ ws_name = get_workspace_display_name(data.get("workspaceId", ""), workspace_map)
1421
+ click.echo(f"Notebook ID: {data.get('notebookId', 'N/A')}")
1422
+ click.echo(f"Workspace: {ws_name}")
1423
+
1424
+ click.echo(f"Status: {data.get('status', 'N/A')}")
1425
+ click.echo(f"Timeout: {data.get('timeout', 'N/A')}")
1426
+ click.echo(f"Queued At: {data.get('queuedAt', 'N/A')}")
1427
+ click.echo(f"Started At: {data.get('startedAt', 'N/A')}")
1428
+ click.echo(f"Completed At: {data.get('completedAt', 'N/A')}")
1429
+ if data.get("parameters"):
1430
+ click.echo("\nParameters:")
1431
+ click.echo(json.dumps(data["parameters"], indent=2))
1432
+ if data.get("result"):
1433
+ click.echo("\nResult:")
1434
+ click.echo(json.dumps(data["result"], indent=2))
1435
+ # Display platform-specific error field with precise label
1436
+ if is_sls and data.get("exception"):
1437
+ click.echo("\nException:")
1438
+ click.echo(data["exception"])
1439
+ elif not is_sls and data.get("errorMessage"):
1440
+ click.echo("\nError Message:")
1441
+ click.echo(data["errorMessage"])
1442
+ except Exception as exc: # noqa: BLE001
1443
+ handle_api_error(exc)
1444
+
1445
+ @notebook_execute.command(name="start")
1446
+ @click.option(
1447
+ "--notebook-id",
1448
+ "-n",
1449
+ required=True,
1450
+ help="Notebook ID (SLE) or notebook path (SLS) to execute",
1451
+ )
1452
+ @click.option(
1453
+ "--workspace",
1454
+ "-w",
1455
+ default="Default",
1456
+ help="Workspace name or ID (SLE only, default: 'Default')",
1457
+ )
1458
+ @click.option(
1459
+ "--parameters",
1460
+ "-p",
1461
+ help="Raw JSON string or @file for parameters passed to execution service",
1462
+ )
1463
+ @click.option(
1464
+ "--timeout",
1465
+ "-t",
1466
+ type=int,
1467
+ default=1800,
1468
+ show_default=True,
1469
+ help="Execution timeout in seconds",
1470
+ )
1471
+ @click.option(
1472
+ "--no-cache",
1473
+ is_flag=True,
1474
+ help="Disable result caching (sets resultCachePeriod=0)",
1475
+ )
1476
+ @click.option(
1477
+ "--format",
1478
+ "-f",
1479
+ type=click.Choice(["table", "json"]),
1480
+ default="table",
1481
+ show_default=True,
1482
+ help="Output format",
1483
+ )
1484
+ def start_notebook_execution(
1485
+ notebook_id: str,
1486
+ workspace: str = "Default",
1487
+ parameters: Optional[str] = None,
1488
+ timeout: int = 1800,
1489
+ no_cache: bool = False,
1490
+ format: str = "table",
1491
+ ) -> None:
1492
+ """Start a notebook execution and return immediately."""
1493
+ format_output = validate_output_format(format)
1494
+ base = _get_notebook_execution_base()
1495
+ is_sls = get_platform() == PLATFORM_SLS
1496
+
1497
+ # Warn if workspace is specified on SLS (where it's ignored)
1498
+ if is_sls and workspace != "Default":
1499
+ click.echo(
1500
+ "⚠ Warning: --workspace is ignored on SystemLink Server (SLS). "
1501
+ "SLS does not use workspace filtering for notebook executions.",
1502
+ err=True,
1503
+ )
1504
+
1505
+ # Build CreateExecution payload using helper function
1506
+ create_execution = _build_create_execution_payload(
1507
+ notebook_id=notebook_id,
1508
+ workspace=workspace,
1509
+ timeout=timeout,
1510
+ no_cache=no_cache,
1511
+ parameters=parameters,
1512
+ is_sls=is_sls,
1513
+ )
1514
+ # The API expects an array of CreateExecution objects.
1515
+ payload: List[Dict[str, Any]] = [create_execution]
1516
+ url = f"{base}/executions"
1517
+ try:
1518
+ # Use direct requests.post because make_api_request only supports dict payloads.
1519
+ headers = get_headers("application/json")
1520
+ resp = requests.post(url, headers=headers, json=payload, verify=get_ssl_verify())
1521
+ resp.raise_for_status()
1522
+ resp_data = resp.json()
1523
+
1524
+ # Parse response using helper function
1525
+ executions = _parse_execution_response(resp_data, is_sls)
1526
+
1527
+ if not executions:
1528
+ # API may return error in BaseResponse.error
1529
+ if isinstance(resp_data, dict):
1530
+ error_obj = resp_data.get("error")
1531
+ if error_obj:
1532
+ click.echo(
1533
+ f"✗ Error: {error_obj.get('message', 'Unknown error')}", err=True
1534
+ )
1535
+ sys.exit(ExitCodes.GENERAL_ERROR)
1536
+ click.echo("✗ No execution returned by service", err=True)
1537
+ sys.exit(ExitCodes.GENERAL_ERROR)
1538
+ execution = executions[0]
1539
+ if format_output == "json":
1540
+ # Emit the first execution for convenience (matches table output scope)
1541
+ click.echo(json.dumps(execution, indent=2))
1542
+ return
1543
+ click.echo("Notebook Execution Result:")
1544
+ click.echo("=" * 50)
1545
+ click.echo(f"Execution ID: {execution.get('id', 'N/A')}")
1546
+ click.echo(f"Status: {execution.get('status', 'N/A')}")
1547
+ if "cachedResult" in execution:
1548
+ cached = execution.get("cachedResult")
1549
+ note = " (served from cache)" if cached else ""
1550
+ click.echo(f"Cached Result: {cached}{note}")
1551
+ if execution.get("result"):
1552
+ click.echo("\nResult:")
1553
+ click.echo(json.dumps(execution["result"], indent=2))
1554
+ if execution.get("errorMessage"):
1555
+ click.echo("\nError Message:")
1556
+ click.echo(execution["errorMessage"])
1557
+ except Exception as exc: # noqa: BLE001
1558
+ handle_api_error(exc)
1559
+
1560
+ @notebook_execute.command(name="sync")
1561
+ @click.option(
1562
+ "--notebook-id",
1563
+ "-n",
1564
+ required=True,
1565
+ help="Notebook ID (SLE) or notebook path (SLS) to execute synchronously",
1566
+ )
1567
+ @click.option(
1568
+ "--workspace",
1569
+ "-w",
1570
+ default="Default",
1571
+ help="Workspace name or ID (SLE only, default: 'Default')",
1572
+ )
1573
+ @click.option(
1574
+ "--parameters",
1575
+ "-p",
1576
+ help="Raw JSON string or @file for parameters passed to execution service",
1577
+ )
1578
+ @click.option(
1579
+ "--timeout",
1580
+ "-t",
1581
+ type=int,
1582
+ default=1800,
1583
+ show_default=True,
1584
+ help="Execution timeout in seconds (server-side; 0 for infinite)",
1585
+ )
1586
+ @click.option(
1587
+ "--poll-interval",
1588
+ type=float,
1589
+ default=2.0,
1590
+ show_default=True,
1591
+ help="Polling interval when waiting for completion",
1592
+ )
1593
+ @click.option(
1594
+ "--max-wait",
1595
+ type=int,
1596
+ default=None,
1597
+ help="Maximum seconds to wait client-side (default: timeout + 60 if set)",
1598
+ )
1599
+ @click.option(
1600
+ "--format",
1601
+ "-f",
1602
+ type=click.Choice(["table", "json"]),
1603
+ default="table",
1604
+ show_default=True,
1605
+ help="Output format",
1606
+ )
1607
+ @click.option(
1608
+ "--no-cache",
1609
+ is_flag=True,
1610
+ help="Disable result caching (sets resultCachePeriod=0)",
1611
+ )
1612
+ def execute_notebook_sync(
1613
+ notebook_id: str,
1614
+ workspace: str = "Default",
1615
+ parameters: Optional[str] = None,
1616
+ timeout: int = 1800,
1617
+ poll_interval: float = 2.0,
1618
+ max_wait: Optional[int] = None,
1619
+ no_cache: bool = False,
1620
+ format: str = "table",
1621
+ ) -> None:
1622
+ """Execute a notebook and wait for completion."""
1623
+ format_output = validate_output_format(format)
1624
+ base = _get_notebook_execution_base()
1625
+ is_sls = get_platform() == PLATFORM_SLS
1626
+
1627
+ # Build CreateExecution payload using helper function
1628
+ create_execution = _build_create_execution_payload(
1629
+ notebook_id=notebook_id,
1630
+ workspace=workspace,
1631
+ timeout=timeout,
1632
+ no_cache=no_cache,
1633
+ parameters=parameters,
1634
+ is_sls=is_sls,
1635
+ )
1636
+ payload: List[Dict[str, Any]] = [create_execution]
1637
+ url = f"{base}/executions"
1638
+ try:
1639
+ headers = get_headers("application/json")
1640
+ resp = requests.post(url, headers=headers, json=payload, verify=get_ssl_verify())
1641
+ resp.raise_for_status()
1642
+ resp_data = resp.json()
1643
+
1644
+ # Parse response using helper function
1645
+ executions = _parse_execution_response(resp_data, is_sls)
1646
+
1647
+ if not executions:
1648
+ if isinstance(resp_data, dict):
1649
+ error_obj = resp_data.get("error")
1650
+ if error_obj:
1651
+ click.echo(
1652
+ f"✗ Error creating execution: {error_obj.get('message', 'Unknown error')}",
1653
+ err=True,
1654
+ )
1655
+ sys.exit(ExitCodes.GENERAL_ERROR)
1656
+ click.echo("✗ No execution returned by service", err=True)
1657
+ sys.exit(ExitCodes.GENERAL_ERROR)
1658
+ execution = executions[0]
1659
+ execution_id = execution.get("id")
1660
+ if not execution_id:
1661
+ click.echo("✗ Execution response missing ID", err=True)
1662
+ sys.exit(ExitCodes.GENERAL_ERROR)
1663
+ # Polling loop
1664
+ terminal_statuses = {"SUCCEEDED", "FAILED", "CANCELED", "TIMED_OUT"}
1665
+ spinner_frames = ["|", "/", "-", "\\"]
1666
+ spinner_index = 0
1667
+ start_time = time.time()
1668
+ computed_max_wait = (
1669
+ max_wait if max_wait is not None else (timeout + 60 if timeout else None)
1670
+ )
1671
+ status = execution.get("status", "QUEUED")
1672
+ last_print_status = ""
1673
+ while status not in terminal_statuses:
1674
+ # Respect client-side max wait
1675
+ if computed_max_wait is not None and (time.time() - start_time) > computed_max_wait:
1676
+ click.echo("\n✗ Reached client-side max wait timeout", err=True)
1677
+ sys.exit(ExitCodes.GENERAL_ERROR)
1678
+ spinner = spinner_frames[spinner_index % len(spinner_frames)]
1679
+ spinner_index += 1
1680
+ # Fetch latest execution state
1681
+ try:
1682
+ exec_resp = make_api_request(
1683
+ "GET", f"{base}/executions/{execution_id}", payload=None
1684
+ ).json()
1685
+ status = exec_resp.get("status", status)
1686
+ execution = exec_resp
1687
+ except Exception: # noqa: BLE001
1688
+ # Transient fetch error; continue (error already printed by handler)
1689
+ pass
1690
+ if format_output == "json":
1691
+ # Suppress spinner for machine-readable output (could add to stderr)
1692
+ pass
1693
+ else:
1694
+ if status != last_print_status:
1695
+ click.echo("") # newline when status changes
1696
+ last_print_status = status
1697
+ msg = (
1698
+ f"{spinner} Waiting for execution {execution_id} | Status: {status} | "
1699
+ f"Elapsed: {int(time.time() - start_time)}s"
1700
+ )
1701
+ # carriage return for inline spinner
1702
+ click.echo(msg, nl=False)
1703
+ click.echo("\r", nl=False)
1704
+ time.sleep(poll_interval)
1705
+ # Finished
1706
+ if format_output == "json":
1707
+ click.echo(json.dumps(execution, indent=2))
1708
+ return
1709
+ click.echo("") # ensure newline after spinner line
1710
+ click.echo("Notebook Execution Completed:")
1711
+ click.echo("=" * 50)
1712
+ click.echo(f"Execution ID: {execution.get('id', 'N/A')}")
1713
+ click.echo(f"Status: {execution.get('status', 'N/A')}")
1714
+ if "cachedResult" in execution:
1715
+ cached = execution.get("cachedResult")
1716
+ note = " (served from cache)" if cached else ""
1717
+ click.echo(f"Cached Result: {cached}{note}")
1718
+ if execution.get("result"):
1719
+ click.echo("\nResult:")
1720
+ click.echo(json.dumps(execution["result"], indent=2))
1721
+ if execution.get("errorMessage"):
1722
+ click.echo("\nError Message:")
1723
+ click.echo(execution["errorMessage"])
1724
+ except Exception as exc: # noqa: BLE001
1725
+ handle_api_error(exc)
1726
+
1727
+ @notebook_execute.command(name="cancel")
1728
+ @click.option("--id", "-i", "execution_id", required=True, help="Execution ID to cancel")
1729
+ def cancel_notebook_execution(execution_id: str) -> None:
1730
+ """Cancel a notebook execution."""
1731
+ base = _get_notebook_execution_base()
1732
+ is_sls = get_platform() == PLATFORM_SLS
1733
+
1734
+ try:
1735
+ if is_sls:
1736
+ # SLS uses bulk cancel endpoint with array of IDs
1737
+ url = f"{base}/cancel-executions"
1738
+ headers = get_headers("application/json")
1739
+ resp = requests.post(
1740
+ url, headers=headers, json=[execution_id], verify=get_ssl_verify()
1741
+ )
1742
+ resp.raise_for_status()
1743
+ else:
1744
+ # SLE uses individual cancel endpoint
1745
+ url = f"{base}/executions/{execution_id}/cancel"
1746
+ make_api_request("POST", url, payload={})
1747
+ format_success("Notebook execution cancellation requested", {"ID": execution_id})
1748
+ except Exception as exc: # noqa: BLE001
1749
+ handle_api_error(exc)
1750
+
1751
+ @notebook_execute.command(name="retry")
1752
+ @click.option("--id", "-i", "execution_id", required=True, help="Execution ID to retry")
1753
+ def retry_notebook_execution(execution_id: str) -> None:
1754
+ """Retry a failed notebook execution (SLE only)."""
1755
+ is_sls = get_platform() == PLATFORM_SLS
1756
+ if is_sls:
1757
+ click.echo(
1758
+ "✗ Execution retry is not available on SystemLink Server. "
1759
+ "Please create a new execution instead.",
1760
+ err=True,
1761
+ )
1762
+ sys.exit(ExitCodes.INVALID_INPUT)
1763
+
1764
+ base = _get_notebook_execution_base()
1765
+ url = f"{base}/executions/{execution_id}/retry"
1766
+ try:
1767
+ data = make_api_request("POST", url, payload={}).json()
1768
+ format_success("Notebook execution retry started", {"ID": data.get("id", execution_id)})
1769
+ except Exception as exc: # noqa: BLE001
1770
+ handle_api_error(exc)