systemlink-cli 1.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/notebook_click.py
ADDED
|
@@ -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)
|