dsh-scnet 0.4.1 → 0.5.0

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 (38) hide show
  1. package/README.md +20 -113
  2. package/README_CN.md +20 -1
  3. package/docs/installation.md +10 -0
  4. package/docs/installation.zh-CN.md +13 -1
  5. package/docs/testing.md +11 -0
  6. package/docs/testing.zh-CN.md +10 -0
  7. package/index.mjs +184 -0
  8. package/package.json +4 -2
  9. package/skills/scnet-hpc/SKILL.md +63 -105
  10. package/skills/scnet-hpc/VERSION +1 -0
  11. package/skills/scnet-hpc/clusters/_template.conf +10 -4
  12. package/skills/scnet-hpc/references/adding-cluster.md +6 -1
  13. package/skills/scnet-hpc/references/backends.md +177 -0
  14. package/skills/scnet-hpc/references/notebook.md +60 -0
  15. package/skills/scnet-hpc/references/openapi.md +140 -0
  16. package/skills/scnet-hpc/references/quickstart-en.md +22 -0
  17. package/skills/scnet-hpc/references/setup.md +4 -0
  18. package/skills/scnet-hpc/scripts/__pycache__/compute-probe.cpython-312.pyc +0 -0
  19. package/skills/scnet-hpc/scripts/__pycache__/scnet.cpython-312.pyc +0 -0
  20. package/skills/scnet-hpc/scripts/__pycache__/scnet_config.cpython-312.pyc +0 -0
  21. package/skills/scnet-hpc/scripts/__pycache__/scnet_credentials.cpython-312.pyc +0 -0
  22. package/skills/scnet-hpc/scripts/__pycache__/scnet_version.cpython-312.pyc +0 -0
  23. package/skills/scnet-hpc/scripts/scnet.py +1573 -0
  24. package/skills/scnet-hpc/scripts/scnet_backends/__init__.py +53 -0
  25. package/skills/scnet-hpc/scripts/scnet_backends/base.py +64 -0
  26. package/skills/scnet-hpc/scripts/scnet_backends/external.py +92 -0
  27. package/skills/scnet-hpc/scripts/scnet_backends/openapi.py +771 -0
  28. package/skills/scnet-hpc/scripts/scnet_backends/profile.py +78 -0
  29. package/skills/scnet-hpc/scripts/scnet_backends/ssh.py +258 -0
  30. package/skills/scnet-hpc/scripts/scnet_config.py +175 -0
  31. package/skills/scnet-hpc/scripts/scnet_credentials.py +160 -0
  32. package/skills/scnet-hpc/scripts/scnet_sdk/__init__.py +11 -0
  33. package/skills/scnet-hpc/scripts/scnet_sdk/cli.py +394 -0
  34. package/skills/scnet-hpc/scripts/scnet_sdk/client.py +240 -0
  35. package/skills/scnet-hpc/scripts/scnet_sdk/notebook.py +379 -0
  36. package/skills/scnet-hpc/scripts/scnet_version.py +20 -0
  37. package/skills/scnet-hpc/scripts/setup-ssh.sh +33 -1
  38. package/skills/scnet-hpc/scripts/setup.sh +471 -0
@@ -0,0 +1,771 @@
1
+ """SCNet OpenAPI 2.0 backend using only the Python standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import mimetypes
7
+ import re
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import Any, Mapping
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.parse import quote, urlencode
13
+ from urllib.request import Request, urlopen
14
+
15
+ from .base import Backend, BackendError, require_option
16
+ from scnet_sdk.client import (
17
+ SCNetClient,
18
+ canonical_signature,
19
+ service_endpoint,
20
+ )
21
+
22
+
23
+ STATUS_MAP = {
24
+ "statR": "RUNNING",
25
+ "statQ": "PENDING",
26
+ "statH": "HELD",
27
+ "statS": "SUSPENDED",
28
+ "statE": "EXITING",
29
+ "statC": "COMPLETED",
30
+ "statW": "WAITING",
31
+ "statX": "OTHER",
32
+ }
33
+ WALLTIME_RE = re.compile(r"^(?:[0-9]+-)?[0-9]{1,3}:[0-9]{2}:[0-9]{2}$")
34
+
35
+
36
+ def _bounded_int(options: Mapping[str, Any], name: str, minimum: int) -> int:
37
+ try:
38
+ value = int(options.get(name) if options.get(name) is not None else minimum)
39
+ except (TypeError, ValueError) as exc:
40
+ raise BackendError(f"{name} must be an integer") from exc
41
+ if value < minimum:
42
+ raise BackendError(f"{name} must be >= {minimum}")
43
+ return value
44
+
45
+
46
+ def normalize_job(item: Mapping[str, Any]) -> dict[str, Any]:
47
+ raw_state = item.get("jobStatus") or item.get("state") or ""
48
+ return {
49
+ "job_id": str(item.get("jobId") or item.get("job_id") or ""),
50
+ "name": item.get("jobName") or item.get("name"),
51
+ "state": STATUS_MAP.get(str(raw_state), raw_state),
52
+ "raw_state": raw_state,
53
+ "queue": item.get("queue"),
54
+ "user": item.get("user"),
55
+ "nodes": item.get("nodeNumReq") or item.get("nodeUsed"),
56
+ "cpus": item.get("procNumReq") or item.get("procNumUsed"),
57
+ "gpus": item.get("gpuNumReq") or item.get("gpuNumUsed"),
58
+ "dcus": item.get("dcuNumReq") or item.get("dcuNumUsed"),
59
+ "elapsed": item.get("jobRunTime"),
60
+ "submitted_at": item.get("jobSubmitTime"),
61
+ "started_at": item.get("jobStartTime"),
62
+ "ended_at": item.get("jobEndTime"),
63
+ "exit_code": item.get("exitCode"),
64
+ "reason": item.get("reason"),
65
+ "work_dir": item.get("workDir"),
66
+ "stdout": item.get("outputPath"),
67
+ "stderr": item.get("errorPath"),
68
+ }
69
+
70
+
71
+ class OpenAPIBackend(Backend):
72
+ name = "openapi"
73
+ capabilities = frozenset(
74
+ {
75
+ "clusters",
76
+ "queues",
77
+ "limits",
78
+ "job",
79
+ "logs",
80
+ "submit",
81
+ "cancel",
82
+ "files",
83
+ "mkdir",
84
+ "upload",
85
+ "download",
86
+ }
87
+ )
88
+
89
+ def __init__(self, context):
90
+ super().__init__(context)
91
+ self.client = SCNetClient(context)
92
+
93
+ def _env(self, name: str, default: str | None = None) -> str | None:
94
+ return self.client.env(name, default)
95
+
96
+ def _json_request(
97
+ self,
98
+ method: str,
99
+ url: str,
100
+ *,
101
+ token: str | None = None,
102
+ json_body: Any | None = None,
103
+ form: Mapping[str, Any] | None = None,
104
+ headers: Mapping[str, str] | None = None,
105
+ ) -> Any:
106
+ return self.client.request(
107
+ method,
108
+ url,
109
+ token=token,
110
+ json_body=json_body,
111
+ form=form,
112
+ headers=headers,
113
+ )
114
+
115
+ def _regions(self) -> list[dict[str, Any]]:
116
+ return self.client.regions()
117
+
118
+ def _select_region(
119
+ self, options: Mapping[str, Any]
120
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
121
+ requested = (
122
+ options.get("region")
123
+ or self.context.profile.get("OPENAPI_REGION_ID")
124
+ or self._env("SCNET_OPENAPI_REGION_ID")
125
+ )
126
+ return self.client.select_region(str(requested) if requested else None)
127
+
128
+ def _center(
129
+ self, options: Mapping[str, Any]
130
+ ) -> tuple[dict[str, Any], str, dict[str, Any]]:
131
+ requested = (
132
+ options.get("region")
133
+ or self.context.profile.get("OPENAPI_REGION_ID")
134
+ or self._env("SCNET_OPENAPI_REGION_ID")
135
+ )
136
+ return self.client.center(str(requested) if requested else None)
137
+
138
+ def _enabled_url(self, center: Mapping[str, Any], field: str) -> str:
139
+ return self.client.enabled_url(center, field)
140
+
141
+ def _hpc_context(
142
+ self, options: Mapping[str, Any]
143
+ ) -> tuple[str, str, str, str, dict[str, Any]]:
144
+ center, token, region = self._center(options)
145
+ hpc_url = self._enabled_url(center, "hpcUrls")
146
+ schedulers = self._json_request(
147
+ "GET",
148
+ service_endpoint(hpc_url, "hpc", "/openapi/v2/cluster"),
149
+ token=token,
150
+ )
151
+ if not isinstance(schedulers, list):
152
+ raise BackendError("cluster endpoint returned an unexpected data shape")
153
+ requested = (
154
+ options.get("scheduler_id")
155
+ or self.context.profile.get("OPENAPI_SCHEDULER_ID")
156
+ or self._env("SCNET_OPENAPI_SCHEDULER_ID")
157
+ )
158
+ selected: Mapping[str, Any] | None = None
159
+ if requested:
160
+ selected = next(
161
+ (item for item in schedulers if str(item.get("id")) == str(requested)),
162
+ None,
163
+ )
164
+ if selected is None:
165
+ raise BackendError(f"scheduler {requested!r} is not available")
166
+ elif len(schedulers) == 1:
167
+ selected = schedulers[0]
168
+ else:
169
+ available = ", ".join(
170
+ f"{item.get('text')}({item.get('id')})" for item in schedulers
171
+ )
172
+ raise BackendError(
173
+ "multiple schedulers are available; use --scheduler-id: " + available
174
+ )
175
+ user_info = center.get("clusterUserInfo") or {}
176
+ username = (
177
+ options.get("username")
178
+ or self.context.profile.get("OPENAPI_USERNAME")
179
+ or self._env("SCNET_OPENAPI_USERNAME")
180
+ or user_info.get("userName")
181
+ )
182
+ if not username:
183
+ raise BackendError("OpenAPI username is missing from center response")
184
+ return hpc_url, token, str(selected["id"]), str(username), region
185
+
186
+ def _efile_context(
187
+ self, options: Mapping[str, Any]
188
+ ) -> tuple[str, str, dict[str, Any]]:
189
+ center, token, region = self._center(options)
190
+ return self._enabled_url(center, "efileUrls"), token, region
191
+
192
+ def discover_regions(self) -> list[dict[str, Any]]:
193
+ """Return authorized regions for setup and diagnostics."""
194
+ return self._regions()
195
+
196
+ def discover_region_context(self, region: str) -> dict[str, Any]:
197
+ """Resolve the selected region's scheduler and user without submitting work."""
198
+ _, _, selected = self._center({"region": region})
199
+ _, _, scheduler_id, username, _ = self._hpc_context({"region": region})
200
+ return {
201
+ "region_id": str(selected.get("clusterId") or region),
202
+ "region_name": selected.get("clusterName"),
203
+ "scheduler_id": scheduler_id,
204
+ "username": username,
205
+ }
206
+
207
+ def discover_all_region_contexts(self) -> list[dict[str, Any]]:
208
+ """Resolve every authorized region without requiring IDs from the user."""
209
+ contexts: list[dict[str, Any]] = []
210
+ center_url = self._env(
211
+ "SCNET_OPENAPI_CENTER_URL",
212
+ "https://www.scnet.cn/ac/openapi/v2/center",
213
+ )
214
+ for region in self._regions():
215
+ region_id = str(region.get("clusterId", ""))
216
+ token = region.get("token")
217
+ if region_id == "0" or not token:
218
+ continue
219
+ center = self._json_request("GET", center_url, token=str(token))
220
+ if not isinstance(center, dict):
221
+ continue
222
+ try:
223
+ hpc_url = self._enabled_url(center, "hpcUrls")
224
+ except BackendError:
225
+ # An account can be authorized for platform/model regions that do not
226
+ # expose an HPC service. They are not job-submission targets.
227
+ continue
228
+ schedulers = self._json_request(
229
+ "GET",
230
+ service_endpoint(hpc_url, "hpc", "/openapi/v2/cluster"),
231
+ token=str(token),
232
+ )
233
+ if not isinstance(schedulers, list):
234
+ schedulers = []
235
+ user_info = center.get("clusterUserInfo") or {}
236
+ contexts.append(
237
+ {
238
+ "region_id": region_id,
239
+ "name": region.get("clusterName") or center.get("name"),
240
+ "username": user_info.get("userName"),
241
+ "home_path": user_info.get("homePath"),
242
+ "schedulers": [
243
+ {
244
+ "id": str(item.get("id", "")),
245
+ "name": item.get("text"),
246
+ "type": item.get("JobManagerType"),
247
+ }
248
+ for item in schedulers
249
+ if isinstance(item, dict)
250
+ ],
251
+ }
252
+ )
253
+ return contexts
254
+
255
+ def op_clusters(self, options: Mapping[str, Any]) -> Any:
256
+ regions = self._regions()
257
+ result = [
258
+ {
259
+ "region_id": str(item.get("clusterId", "")),
260
+ "name": item.get("clusterName"),
261
+ "available": bool(item.get("token")),
262
+ }
263
+ for item in regions
264
+ if str(item.get("clusterId", "")) != "0"
265
+ ]
266
+ if options.get("raw"):
267
+ safe_raw = [
268
+ {
269
+ **item,
270
+ "token": "<redacted>",
271
+ }
272
+ for item in regions
273
+ ]
274
+ return {"items": result, "raw": safe_raw}
275
+ return result
276
+
277
+ def op_queues(self, options: Mapping[str, Any]) -> Any:
278
+ hpc_url, token, scheduler_id, username, _ = self._hpc_context(options)
279
+ params = urlencode({"strJobManagerID": scheduler_id})
280
+ url = service_endpoint(
281
+ hpc_url,
282
+ "hpc",
283
+ f"/openapi/v2/queuenames/users/{quote(username)}?{params}",
284
+ )
285
+ data = self._json_request("GET", url, token=token)
286
+ result = []
287
+ for item in data or []:
288
+ result.append(
289
+ {
290
+ "partition": item.get("queueName") or item.get("id"),
291
+ "nodes": item.get("queNodes"),
292
+ "free_nodes": item.get("queFreeNodes"),
293
+ "cpus": item.get("queNcpus"),
294
+ "free_cpus": item.get("queFreeNcpus"),
295
+ "max_cpus": item.get("queMaxNcpus"),
296
+ "max_cpus_per_node": item.get("queMaxPPN"),
297
+ "max_nodes": item.get("queMaxNodect"),
298
+ "max_walltime": item.get("queMaxWalltime"),
299
+ "max_gpus_per_node": item.get("queMaxGpuPN"),
300
+ "max_dcus_per_node": item.get("queMaxDcuPN"),
301
+ "charge_rate": item.get("queChargeRate"),
302
+ }
303
+ )
304
+ return {"items": result, "raw": data} if options.get("raw") else result
305
+
306
+ def op_limits(self, options: Mapping[str, Any]) -> Any:
307
+ hpc_url, token, scheduler_id, _, _ = self._hpc_context(options)
308
+ params = urlencode({"strJobManagerID": scheduler_id})
309
+ data = self._json_request(
310
+ "GET",
311
+ service_endpoint(
312
+ hpc_url, "hpc", f"/openapi/v2/userquotas/userlimit?{params}"
313
+ ),
314
+ token=token,
315
+ )
316
+ if not isinstance(data, dict):
317
+ return data
318
+ key_map = {
319
+ "userMaxCpu": "user_max_cpus",
320
+ "userMaxDcu": "user_max_dcus",
321
+ "userMaxGpu": "user_max_gpus",
322
+ "userMaxMlu": "user_max_mlus",
323
+ "userMaxMem": "user_max_memory_mb",
324
+ "userMaxNode": "user_max_nodes",
325
+ "userMaxSubmitJob": "user_max_submitted_jobs",
326
+ "userMaxRunJob": "user_max_running_jobs",
327
+ "accountMaxCpu": "account_max_cpus",
328
+ "accountMaxDcu": "account_max_dcus",
329
+ "accountMaxGpu": "account_max_gpus",
330
+ "accountMaxNode": "account_max_nodes",
331
+ "maxWallTime": "remaining_machine_time_seconds",
332
+ }
333
+ result = {target: data.get(source) for source, target in key_map.items()}
334
+ return {"data": result, "raw": data} if options.get("raw") else result
335
+
336
+ def op_job(self, options: Mapping[str, Any]) -> Any:
337
+ job_id = str(require_option(options, "job_id"))
338
+ hpc_url, token, _, _, _ = self._hpc_context(options)
339
+ data = self._json_request(
340
+ "GET",
341
+ service_endpoint(hpc_url, "hpc", f"/openapi/v2/jobs/{quote(job_id)}"),
342
+ token=token,
343
+ )
344
+ if not isinstance(data, dict):
345
+ raise BackendError("job endpoint returned an unexpected data shape")
346
+ result = normalize_job(data)
347
+ if options.get("raw"):
348
+ result["raw"] = data
349
+ return result
350
+
351
+ def op_logs(self, options: Mapping[str, Any]) -> Any:
352
+ path = str(require_option(options, "path"))
353
+ if not path.startswith("/"):
354
+ raise BackendError("OpenAPI log path must be absolute")
355
+ hpc_url, token, _, _, _ = self._hpc_context(options)
356
+ direction = options.get("direction") or "tail"
357
+ roll = "UP" if direction == "tail" else "DOWN"
358
+ page = int(options.get("page") or 1)
359
+ data = self._json_request(
360
+ "POST",
361
+ service_endpoint(hpc_url, "hpc", "/openapi/v2/file/content"),
362
+ token=token,
363
+ form={
364
+ "hostName": options.get("host_name") or "",
365
+ "dirPath": path,
366
+ "triggerNum": page,
367
+ "rollDirection": roll,
368
+ },
369
+ )
370
+ if not isinstance(data, dict):
371
+ raise BackendError("log endpoint returned an unexpected data shape")
372
+ lines = str(data.get("data") or "").splitlines()
373
+ requested_lines = int(options.get("lines") or 200)
374
+ if direction == "tail":
375
+ lines = lines[-requested_lines:]
376
+ else:
377
+ lines = lines[:requested_lines]
378
+ result = {
379
+ "path": path,
380
+ "direction": direction,
381
+ "page": page,
382
+ "total_pages": data.get("totalTriggerTimes"),
383
+ "total_lines": data.get("allLineTotal"),
384
+ "lines": lines,
385
+ }
386
+ return {"data": result, "raw": data} if options.get("raw") else result
387
+
388
+ def _submit_payload(self, options: Mapping[str, Any]) -> dict[str, Any]:
389
+ name = str(require_option(options, "name"))
390
+ command = str(require_option(options, "command"))
391
+ work_dir = str(require_option(options, "work_dir"))
392
+ queue = str(require_option(options, "queue"))
393
+ if not work_dir.startswith("/"):
394
+ raise BackendError("OpenAPI work directory must be absolute")
395
+ if any(char in name for char in "\r\n") or any(
396
+ char in queue for char in "\r\n"
397
+ ):
398
+ raise BackendError("job name and queue must not contain newlines")
399
+ nodes = _bounded_int(options, "nodes", 1)
400
+ cpus = _bounded_int(options, "cpus", 1)
401
+ gpus = _bounded_int(options, "gpus", 0)
402
+ dcus = _bounded_int(options, "dcus", 0)
403
+ walltime = str(options.get("walltime") or "24:00:00")
404
+ if not WALLTIME_RE.fullmatch(walltime):
405
+ raise BackendError("walltime must use HH:MM:SS or D-HH:MM:SS")
406
+ scheduler_options = options.get("scheduler_options") or []
407
+ if not isinstance(scheduler_options, (list, tuple)):
408
+ raise BackendError("scheduler_options must be a list")
409
+ return {
410
+ "name": name,
411
+ "command": command,
412
+ "work_dir": work_dir,
413
+ "queue": queue,
414
+ "nodes": nodes,
415
+ "cpus": cpus,
416
+ "gpus": gpus,
417
+ "dcus": dcus,
418
+ "memory": str(options.get("memory") or ""),
419
+ "walltime": walltime,
420
+ "exclusive": bool(options.get("exclusive")),
421
+ "stdout": options.get("stdout")
422
+ or f"{work_dir.rstrip('/')}/std.out.%j",
423
+ "stderr": options.get("stderr")
424
+ or f"{work_dir.rstrip('/')}/std.err.%j",
425
+ "scheduler_options": list(scheduler_options),
426
+ }
427
+
428
+ def _submit_request(self, options: Mapping[str, Any]) -> tuple[str, str, dict[str, Any]]:
429
+ payload = self._submit_payload(options)
430
+ hpc_url, token, scheduler_id, _, region = self._hpc_context(options)
431
+ job_info = {
432
+ "GAP_CMD_FILE": payload["command"],
433
+ "GAP_NNODE": str(payload["nodes"]),
434
+ "GAP_NODE_STRING": options.get("node_string") or "",
435
+ "GAP_SUBMIT_TYPE": "cmd",
436
+ "GAP_JOB_NAME": payload["name"],
437
+ "GAP_WORK_DIR": payload["work_dir"],
438
+ "GAP_QUEUE": payload["queue"],
439
+ "GAP_NPROC": str(payload["cpus"]),
440
+ "GAP_PPN": "",
441
+ "GAP_NGPU": str(payload["gpus"] or ""),
442
+ "GAP_NDCU": str(payload["dcus"] or ""),
443
+ "GAP_JOB_MEM": payload["memory"],
444
+ "GAP_WALL_TIME": payload["walltime"],
445
+ "GAP_EXCLUSIVE": "1" if payload["exclusive"] else "",
446
+ "GAP_APPNAME": "BASE",
447
+ "GAP_MULTI_SUB": "",
448
+ "GAP_STD_OUT_FILE": payload["stdout"],
449
+ "GAP_STD_ERR_FILE": payload["stderr"],
450
+ "GAP_SCHEDULER_OPT_WEB": "\n".join(payload["scheduler_options"]),
451
+ "GAP_CLUSTER_ID": str(region.get("clusterId") or ""),
452
+ }
453
+ return hpc_url, token, {
454
+ "strJobManagerID": scheduler_id,
455
+ "mapAppJobInfo": job_info,
456
+ }
457
+
458
+ def preview_submit(self, options: Mapping[str, Any]) -> Any:
459
+ payload = self._submit_payload(options)
460
+ return {
461
+ "backend": self.name,
462
+ "operation": "submit",
463
+ "requires_network_resolution": True,
464
+ "request": payload,
465
+ "note": "scheduler and region IDs will be resolved before POST; no request sent",
466
+ }
467
+
468
+ def op_submit(self, options: Mapping[str, Any]) -> Any:
469
+ hpc_url, token, body = self._submit_request(options)
470
+ data = self._json_request(
471
+ "POST",
472
+ service_endpoint(
473
+ hpc_url, "hpc", "/openapi/v2/apptemplates/BASIC/BASE/job"
474
+ ),
475
+ token=token,
476
+ json_body=body,
477
+ )
478
+ result = {"job_id": str(data)}
479
+ return {"data": result, "raw": data} if options.get("raw") else result
480
+
481
+ def op_cancel(self, options: Mapping[str, Any]) -> Any:
482
+ job_id = str(require_option(options, "job_id"))
483
+ hpc_url, token, scheduler_id, username, _ = self._hpc_context(options)
484
+ method = self._env("SCNET_OPENAPI_CANCEL_METHOD", "5")
485
+ data = self._json_request(
486
+ "DELETE",
487
+ service_endpoint(hpc_url, "hpc", "/openapi/v2/jobs"),
488
+ token=token,
489
+ form={
490
+ "jobMethod": method,
491
+ "strJobInfoMap": f"{scheduler_id},{username}:{job_id}:",
492
+ },
493
+ )
494
+ return {"job_id": job_id, "cancelled": True, "result": data}
495
+
496
+ def preview_cancel(self, options: Mapping[str, Any]) -> Any:
497
+ job_id = str(require_option(options, "job_id"))
498
+ return {
499
+ "backend": self.name,
500
+ "operation": "cancel",
501
+ "job_id": job_id,
502
+ "note": "no DELETE request sent",
503
+ }
504
+
505
+ def op_mkdir(self, options: Mapping[str, Any]) -> Any:
506
+ path = str(require_option(options, "path"))
507
+ if (
508
+ not path.startswith("/")
509
+ or "\n" in path
510
+ or "\r" in path
511
+ or "\x00" in path
512
+ ):
513
+ raise BackendError("directory path must be a safe absolute path")
514
+ efile_url, token, _ = self._efile_context(options)
515
+ url = (
516
+ service_endpoint(efile_url, "efile", "/openapi/v2/file/mkdir")
517
+ + "?"
518
+ + urlencode(
519
+ {
520
+ "path": path,
521
+ "createParents": "true" if options.get("parents", False) else "false",
522
+ }
523
+ )
524
+ )
525
+ data = self._json_request("POST", url, token=token, json_body={})
526
+ result = {"path": path, "created": True}
527
+ return {"data": result, "raw": data} if options.get("raw") else result
528
+
529
+ def preview_mkdir(self, options: Mapping[str, Any]) -> Any:
530
+ path = str(require_option(options, "path"))
531
+ if not path.startswith("/"):
532
+ raise BackendError("directory path must be absolute")
533
+ return {
534
+ "backend": self.name,
535
+ "operation": "mkdir",
536
+ "path": path,
537
+ "parents": bool(options.get("parents", False)),
538
+ "note": "no POST request sent",
539
+ }
540
+
541
+ def op_files(self, options: Mapping[str, Any]) -> Any:
542
+ efile_url, token, _ = self._efile_context(options)
543
+ limit = _bounded_int(options, "limit", 1)
544
+ start = _bounded_int(options, "start", 0)
545
+ params = {
546
+ "limit": limit,
547
+ "start": start,
548
+ "order": options.get("order") or "asc",
549
+ "orderBy": options.get("order_by") or "name",
550
+ }
551
+ if options.get("path"):
552
+ params["path"] = options["path"]
553
+ data = self._json_request(
554
+ "GET",
555
+ service_endpoint(
556
+ efile_url, "efile", "/openapi/v2/file/list"
557
+ )
558
+ + "?"
559
+ + urlencode(params),
560
+ token=token,
561
+ )
562
+ if not isinstance(data, dict):
563
+ return data
564
+ result = {
565
+ "path": data.get("path"),
566
+ "total": data.get("total"),
567
+ "files": [
568
+ {
569
+ "name": item.get("name"),
570
+ "path": item.get("path"),
571
+ "size": item.get("size"),
572
+ "directory": item.get("isDirectory"),
573
+ "modified_at": item.get("lastModifiedTime"),
574
+ "permission": item.get("permission"),
575
+ }
576
+ for item in data.get("fileList") or []
577
+ ],
578
+ }
579
+ return {"data": result, "raw": data} if options.get("raw") else result
580
+
581
+ def op_upload(self, options: Mapping[str, Any]) -> Any:
582
+ local_path = Path(str(require_option(options, "local_path"))).expanduser()
583
+ remote_path = str(require_option(options, "remote_path"))
584
+ if not local_path.is_file():
585
+ raise BackendError(f"local file does not exist: {local_path}")
586
+ if (
587
+ not remote_path.startswith("/")
588
+ or "\n" in remote_path
589
+ or "\r" in remote_path
590
+ or "\x00" in remote_path
591
+ ):
592
+ raise BackendError("remote upload directory must be absolute")
593
+ chunk_size = int(options.get("chunk_size") or 0)
594
+ if chunk_size < 0:
595
+ raise BackendError("chunk size must be non-negative")
596
+ if chunk_size and chunk_size < 1024 * 1024:
597
+ raise BackendError("chunk size must be at least 1 MiB or 0")
598
+ if chunk_size and local_path.stat().st_size > chunk_size:
599
+ return self._op_chunked_upload(local_path, remote_path, options, chunk_size)
600
+ efile_url, token, _ = self._efile_context(options)
601
+ content_type = mimetypes.guess_type(local_path.name)[0] or "application/octet-stream"
602
+ self._multipart_request(
603
+ service_endpoint(efile_url, "efile", "/openapi/v2/file/upload"),
604
+ token,
605
+ {
606
+ "cover": "cover" if options.get("cover") else "uncover",
607
+ "path": remote_path,
608
+ },
609
+ local_path.name,
610
+ local_path.read_bytes(),
611
+ content_type,
612
+ )
613
+ return {"local_path": str(local_path), "remote_path": remote_path}
614
+
615
+ def _multipart_request(
616
+ self,
617
+ url: str,
618
+ token: str,
619
+ fields: Mapping[str, Any],
620
+ file_name: str,
621
+ file_bytes: bytes,
622
+ content_type: str,
623
+ ) -> Any:
624
+ boundary = "----scnet-hpc-" + uuid.uuid4().hex
625
+ parts: list[bytes] = []
626
+ for name, value in fields.items():
627
+ parts.append(
628
+ (
629
+ f"--{boundary}\r\n"
630
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
631
+ f"{value}\r\n"
632
+ ).encode("utf-8")
633
+ )
634
+ safe_name = file_name.replace('"', "_").replace("\r", "_").replace("\n", "_")
635
+ parts.append(
636
+ (
637
+ f"--{boundary}\r\n"
638
+ f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'
639
+ f"Content-Type: {content_type}\r\n\r\n"
640
+ ).encode("utf-8")
641
+ + file_bytes
642
+ + b"\r\n"
643
+ )
644
+ parts.append(f"--{boundary}--\r\n".encode("ascii"))
645
+ request = Request(
646
+ url,
647
+ data=b"".join(parts),
648
+ method="POST",
649
+ headers={
650
+ "token": token,
651
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
652
+ "Accept": "application/json",
653
+ "User-Agent": "scnet-hpc/1",
654
+ },
655
+ )
656
+ try:
657
+ with urlopen(request, timeout=self.context.timeout) as response:
658
+ payload = json.loads(response.read().decode("utf-8"))
659
+ except (HTTPError, URLError, OSError, json.JSONDecodeError) as exc:
660
+ raise BackendError(f"OpenAPI multipart upload failed: {exc}") from exc
661
+ if not isinstance(payload, dict) or str(payload.get("code", "")) != "0":
662
+ raise BackendError(
663
+ f"SCNet OpenAPI error {payload.get('code')}: {payload.get('msg')}"
664
+ )
665
+ return payload.get("data")
666
+
667
+ def _op_chunked_upload(
668
+ self,
669
+ local_path: Path,
670
+ remote_path: str,
671
+ options: Mapping[str, Any],
672
+ chunk_size: int,
673
+ ) -> dict[str, Any]:
674
+ efile_url, token, _ = self._efile_context(options)
675
+ total_size = local_path.stat().st_size
676
+ total_chunks = max(1, (total_size + chunk_size - 1) // chunk_size)
677
+ filename = local_path.name
678
+ relative_path = str(options.get("relative_path") or filename)
679
+ cover = "cover" if options.get("cover") else "uncover"
680
+ identifier = str(options.get("identifier") or f"{total_size}-{filename}")
681
+ endpoint = service_endpoint(efile_url, "efile", "/openapi/v2/file/burst")
682
+ content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
683
+ with local_path.open("rb") as stream:
684
+ for chunk_number in range(1, total_chunks + 1):
685
+ chunk = stream.read(chunk_size)
686
+ if not chunk:
687
+ break
688
+ self._multipart_request(
689
+ endpoint,
690
+ token,
691
+ {
692
+ "chunkNumber": chunk_number,
693
+ "cover": cover,
694
+ "filename": filename,
695
+ "identifier": identifier,
696
+ "path": remote_path,
697
+ "relativePath": relative_path,
698
+ "totalChunks": total_chunks,
699
+ "totalSize": total_size,
700
+ "chunkSize": chunk_size,
701
+ "currentChunkSize": len(chunk),
702
+ },
703
+ filename,
704
+ chunk,
705
+ content_type,
706
+ )
707
+ merge_data = self._json_request(
708
+ "POST",
709
+ service_endpoint(efile_url, "efile", "/openapi/v2/file/merge"),
710
+ token=token,
711
+ form={
712
+ "cover": cover,
713
+ "filename": filename,
714
+ "id": "",
715
+ "identifier": identifier,
716
+ "path": remote_path,
717
+ "relativePath": relative_path,
718
+ },
719
+ )
720
+ result = {
721
+ "local_path": str(local_path),
722
+ "remote_path": remote_path,
723
+ "bytes": total_size,
724
+ "chunks": total_chunks,
725
+ }
726
+ return {"data": result, "raw": merge_data} if options.get("raw") else result
727
+
728
+ def op_download(self, options: Mapping[str, Any]) -> Any:
729
+ remote_path = str(require_option(options, "remote_path"))
730
+ local_path = Path(str(require_option(options, "local_path"))).expanduser()
731
+ if (
732
+ not remote_path.startswith("/")
733
+ or "\n" in remote_path
734
+ or "\r" in remote_path
735
+ or "\x00" in remote_path
736
+ ):
737
+ raise BackendError("remote download path must be absolute")
738
+ if local_path.exists() and not options.get("cover"):
739
+ raise BackendError("local path already exists; pass --cover to overwrite it")
740
+ efile_url, token, _ = self._efile_context(options)
741
+ url = (
742
+ service_endpoint(efile_url, "efile", "/openapi/v2/file/download")
743
+ + "?"
744
+ + urlencode({"path": remote_path})
745
+ )
746
+ request = Request(
747
+ url,
748
+ method="GET",
749
+ headers={"token": token, "User-Agent": "scnet-hpc/1"},
750
+ )
751
+ try:
752
+ with urlopen(request, timeout=self.context.timeout) as response:
753
+ content_type = response.headers.get("Content-Type", "")
754
+ content = response.read()
755
+ except (HTTPError, URLError, OSError) as exc:
756
+ raise BackendError(f"OpenAPI download failed: {exc}") from exc
757
+ if "application/json" in content_type:
758
+ try:
759
+ payload = json.loads(content.decode("utf-8"))
760
+ except (UnicodeDecodeError, json.JSONDecodeError):
761
+ payload = None
762
+ if isinstance(payload, dict) and str(payload.get("code", "0")) != "0":
763
+ raise BackendError(
764
+ f"SCNet OpenAPI error {payload.get('code')}: {payload.get('msg')}"
765
+ )
766
+ local_path.write_bytes(content)
767
+ return {
768
+ "remote_path": remote_path,
769
+ "local_path": str(local_path),
770
+ "bytes": len(content),
771
+ }