iowap-node 2.3.20__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.
@@ -0,0 +1,434 @@
1
+ """CLI file subcommands — generic transfer-ladder send/get (T-164).
2
+
3
+ ``file send`` and ``file get`` are capability-agnostic wrappers that pick
4
+ the right transfer mode (inline / artifact / bridge) based on the server's
5
+ ladder config (``max_inline_bytes`` / ``max_artifact_bytes``) and the
6
+ capability's declared ``upload_modes``. The three modes are:
7
+
8
+ * **inline** — file is base64-encoded and carried in the task payload
9
+ (small files, no storage node needed, RAM-heavy on the handler).
10
+ * **artifact** — file is uploaded to the transient relay artifact store,
11
+ the task payload carries an ``artifact_id`` reference (medium files).
12
+ * **bridge** — file is streamed to/from a storage node through a temp
13
+ bridge route; the task payload carries a ``storage_ref`` so the
14
+ receiving node fetches it directly (large files, RAM-bounded).
15
+
16
+ Handler signatures are plain ``(client, args) -> int``; the
17
+ ``node_cli.with_client`` decorator is applied at parser-registration time.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any, Optional
27
+
28
+ import httpx
29
+
30
+ from nodes.common.cli import cli_bridge
31
+ from nodes.common.relay_client import RelayClient
32
+
33
+ # Reuse the bridge helpers (wait-for-result, bearer header, chunk size).
34
+ _wait_for_result = cli_bridge._wait_for_result
35
+ _bearer = cli_bridge._bearer
36
+ _CHUNK = cli_bridge._CHUNK
37
+
38
+ # Capabilities that accept a file via the bridge two-step flow: a
39
+ # ``storage_ref`` in the task payload points the handler at a channel or
40
+ # backup on a storage node. The node-cli opens the channel by submitting
41
+ # a bridge-capability task, then puts the ref into the actual task.
42
+ _BRIDGE_OPEN_CAPS = {
43
+ "storage.store": "storage.upload_channel",
44
+ "backup.create": "backup.create",
45
+ }
46
+
47
+
48
+ def _choose_mode(
49
+ size: int,
50
+ transfer_cfg: dict[str, Any],
51
+ upload_modes: list[str],
52
+ force: Optional[str] = None,
53
+ ) -> str:
54
+ """Pick the smallest transfer mode the capability supports.
55
+
56
+ The server ladder defines the upper bounds (``max_inline_bytes`` /
57
+ ``max_artifact_bytes``); the capability's ``upload_modes`` restricts
58
+ which rungs are available. We pick the lowest rung that fits both.
59
+ ``force`` overrides the choice but must be in ``upload_modes``.
60
+
61
+ T-179 Task 2b (FROZEN decision, Phase 1): this decision point will
62
+ delegate to ``cli_hp.decide_mode`` (identical behavior, shared
63
+ ladder logic for ``file send`` and ``hp put``). Import must stay
64
+ *function-local* (``from nodes.common.cli import cli_hp`` inside the
65
+ function body) because ``cli_hp`` imports ``_load_capability_modes``
66
+ from this module at module level — a module-level back-import would
67
+ be circular. Phase 3 implements it as::
68
+
69
+ thresholds = {
70
+ "max_inline_bytes": int(transfer_cfg.get("max_inline_bytes", 0)),
71
+ "max_artifact_bytes": int(transfer_cfg.get("max_artifact_bytes", 0)),
72
+ }
73
+ try:
74
+ return cli_hp.decide_mode(size, upload_modes, thresholds, force=force)
75
+ except ValueError as exc:
76
+ raise SystemExit(str(exc))
77
+
78
+ The 0-defaults are materialized here (not inside decide_mode) so
79
+ cli_file keeps its exact current behavior: a missing server key
80
+ forbids the rung on this path, while decide_mode alone treats a
81
+ missing key as unlimited. Error strings stay byte-identical (they
82
+ are part of the frozen decide_mode contract).
83
+ """
84
+ if force is not None:
85
+ if force not in upload_modes:
86
+ raise SystemExit(
87
+ f"--force {force!r} not supported by capability "
88
+ f"(upload_modes={upload_modes})"
89
+ )
90
+ return force
91
+
92
+ # T-179 Task 2b: the decision point is shared with ``hp put`` via
93
+ # ``cli_hp.decide_mode``. The import stays function-local because
94
+ # ``cli_hp`` imports ``_load_capability_modes`` from this module at
95
+ # module level (a module-level back-import would be circular). The
96
+ # 0-defaults are materialized HERE (not inside decide_mode) so
97
+ # cli_file keeps its exact current behavior: a missing server key
98
+ # forbids the rung on this path. Error strings stay byte-identical
99
+ # (frozen decide_mode contract).
100
+ from nodes.common.cli import cli_hp
101
+
102
+ thresholds = {
103
+ "max_inline_bytes": int(transfer_cfg.get("max_inline_bytes", 0)),
104
+ "max_artifact_bytes": int(transfer_cfg.get("max_artifact_bytes", 0)),
105
+ }
106
+ try:
107
+ return cli_hp.decide_mode(size, upload_modes, thresholds, force=force)
108
+ except ValueError as exc:
109
+ raise SystemExit(str(exc))
110
+
111
+
112
+ def _load_capability_modes(client: RelayClient, cap: str) -> dict[str, Any]:
113
+ """Load capability details (upload_modes + input_schema) from the server."""
114
+ try:
115
+ detail = client.get_capability_detail(cap)
116
+ except httpx.HTTPError as exc:
117
+ print(f"failed to load capability details for {cap!r}: {exc}", file=sys.stderr)
118
+ raise SystemExit(1)
119
+ if not detail:
120
+ print(f"capability {cap!r} not found on the relay", file=sys.stderr)
121
+ raise SystemExit(1)
122
+ return detail
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # file send
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _bridge_open_channel(
131
+ client: RelayClient, cap: str, file_path: Path, args
132
+ ) -> dict[str, Any]:
133
+ """Open a bridge channel for ``cap`` and stream ``file_path`` into it.
134
+
135
+ Returns the completed stage result (carrying ``channel_id`` /
136
+ ``backup_id`` + the upload URL was already consumed). The caller
137
+ builds a ``storage_ref`` from the returned id.
138
+ """
139
+ # Map the file-bearing capability to the bridge-opener capability.
140
+ if cap == "backup.create":
141
+ payload: dict[str, Any] = {
142
+ "source": args.source or file_path.name,
143
+ "type": args.type or "full",
144
+ "mode": "bridge",
145
+ "filename": file_path.name,
146
+ }
147
+ open_cap = "backup.create"
148
+ else:
149
+ # Default: storage.upload_channel (storage.store and others).
150
+ payload = {}
151
+ open_cap = "storage.upload_channel"
152
+
153
+ resp = client.submit_simple_task(
154
+ open_cap, payload, name=args.name or "", priority=args.priority
155
+ )
156
+ task_id = resp.get("task_id") or resp.get("id")
157
+ if not task_id:
158
+ print(f"no task_id in submit response: {resp}", file=sys.stderr)
159
+ raise SystemExit(1)
160
+
161
+ result = _wait_for_result(client, str(task_id), args.interval)
162
+ if result is None:
163
+ raise SystemExit(1)
164
+ url = result.get("upload_url")
165
+ if not url:
166
+ print(f"no upload_url in bridge result: {result}", file=sys.stderr)
167
+ raise SystemExit(1)
168
+
169
+ headers = _bearer(client)
170
+ headers["X-Filename"] = file_path.name
171
+ try:
172
+ with file_path.open("rb") as f, httpx.stream(
173
+ "POST", url, headers=headers, content=f,
174
+ timeout=httpx.Timeout(30.0, read=None),
175
+ ) as r:
176
+ r.raise_for_status()
177
+ except httpx.HTTPError as exc:
178
+ print(f"bridge upload failed: {exc}", file=sys.stderr)
179
+ raise SystemExit(1)
180
+ return result
181
+
182
+
183
+ def _cmd_file_send(client: RelayClient, args) -> int:
184
+ file_path = Path(args.file)
185
+ if not file_path.is_file():
186
+ print(f"file not found: {file_path}", file=sys.stderr)
187
+ return 2
188
+ size = file_path.stat().st_size
189
+
190
+ # 1. Load the server ladder config.
191
+ try:
192
+ transfer_cfg = client.get_transfer_config()
193
+ except httpx.HTTPError as exc:
194
+ print(f"failed to load transfer config: {exc}", file=sys.stderr)
195
+ return 1
196
+
197
+ # 2. Load capability details (upload_modes).
198
+ cap_detail = _load_capability_modes(client, args.cap)
199
+ upload_modes = list(cap_detail.get("upload_modes") or ["inline", "artifact", "bridge"])
200
+
201
+ # 3. Choose the mode.
202
+ mode = _choose_mode(size, transfer_cfg, upload_modes, force=args.force)
203
+
204
+ # 4. Derive the file field from the capability input_schema (like
205
+ # file get): storage.store → path, backup.create → source, else path.
206
+ schema = cap_detail.get("input_schema") or {}
207
+ fields = (schema.get("fields") or {}) if isinstance(schema, dict) else {}
208
+ if "source" in fields:
209
+ file_field = "source"
210
+ else:
211
+ file_field = "path"
212
+
213
+ # 5. Orchestrate.
214
+ if mode == "inline":
215
+ data_b64 = base64.b64encode(file_path.read_bytes()).decode("ascii")
216
+ payload: dict[str, Any] = {
217
+ file_field: args.name or file_path.name,
218
+ "data_base64": data_b64,
219
+ }
220
+ if file_field == "source":
221
+ payload["type"] = args.type or "full"
222
+ resp = client.submit_simple_task(
223
+ args.cap, payload, name=args.name or "", priority=args.priority,
224
+ owner_node_id=args.owner,
225
+ )
226
+ _print_send_result(args, resp, mode, size)
227
+ return 0
228
+
229
+ if mode == "artifact":
230
+ art = client.upload_artifact(file_path, name=args.name or file_path.name)
231
+ artifact_id = art.get("artifact_id")
232
+ if not artifact_id:
233
+ print(f"artifact upload returned no artifact_id: {art}", file=sys.stderr)
234
+ return 1
235
+ payload = {
236
+ file_field: args.name or file_path.name,
237
+ "artifact_id": artifact_id,
238
+ }
239
+ if file_field == "source":
240
+ payload["type"] = args.type or "full"
241
+ resp = client.submit_simple_task(
242
+ args.cap, payload, name=args.name or "", priority=args.priority,
243
+ owner_node_id=args.owner,
244
+ )
245
+ _print_send_result(args, resp, mode, size, artifact_id=artifact_id)
246
+ return 0
247
+
248
+ # mode == "bridge": two-step flow.
249
+ result = _bridge_open_channel(client, args.cap, file_path, args)
250
+ ref_id = result.get("backup_id") or result.get("channel_id")
251
+ ref_type = "backup" if result.get("backup_id") else "channel"
252
+ if not ref_id:
253
+ print(f"bridge channel returned no id: {result}", file=sys.stderr)
254
+ return 1
255
+ storage_ref = {
256
+ "type": ref_type,
257
+ "id": ref_id,
258
+ "filename": file_path.name,
259
+ }
260
+ payload = {
261
+ "path": args.name or file_path.name,
262
+ "storage_ref": storage_ref,
263
+ }
264
+ resp = client.submit_simple_task(
265
+ args.cap, payload, name=args.name or "", priority=args.priority,
266
+ owner_node_id=args.owner,
267
+ )
268
+ _print_send_result(args, resp, mode, size, ref=storage_ref)
269
+ return 0
270
+
271
+
272
+ def _print_send_result(
273
+ args, resp: dict[str, Any], mode: str, size: int, *,
274
+ artifact_id: Optional[str] = None, ref: Optional[dict] = None,
275
+ ) -> None:
276
+ if args.json:
277
+ out = {"status": "sent", "mode": mode, "size_bytes": size, "submit": resp}
278
+ if artifact_id:
279
+ out["artifact_id"] = artifact_id
280
+ if ref:
281
+ out["storage_ref"] = ref
282
+ print(json.dumps(out, default=str))
283
+ return
284
+ tag = f" (artifact={artifact_id})" if artifact_id else ""
285
+ tag = tag or (f" (bridge={ref['type']}:{ref['id']})" if ref else "")
286
+ print(f"✅ Sent {size} bytes via {mode}{tag}")
287
+ print(json.dumps(resp, default=str))
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # file get
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ def _cmd_file_get(client: RelayClient, args) -> int:
296
+ # Load capability details → upload_modes + input_schema determine how
297
+ # <ref> is interpreted.
298
+ cap_detail = _load_capability_modes(client, args.cap)
299
+ upload_modes = list(cap_detail.get("upload_modes") or ["inline", "artifact", "bridge"])
300
+ schema = cap_detail.get("input_schema") or {}
301
+ fields = (schema.get("fields") or {}) if isinstance(schema, dict) else {}
302
+
303
+ # Which payload field carries <ref>? Derived from input_schema.
304
+ if "path" in fields:
305
+ ref_field = "path"
306
+ elif "backup_id" in fields:
307
+ ref_field = "backup_id"
308
+ elif "channel_id" in fields:
309
+ ref_field = "channel_id"
310
+ else:
311
+ print(
312
+ f"capability {args.cap!r} has no file-reference field "
313
+ f"(path/backup_id/channel_id) in its input_schema",
314
+ file=sys.stderr,
315
+ )
316
+ return 1
317
+
318
+ # Prefer the bridge download when the capability offers bridge —
319
+ # otherwise inline (submit the task, the result carries data_base64).
320
+ if "bridge" in upload_modes and ref_field in ("backup_id", "channel_id"):
321
+ return _file_get_bridge(client, args, ref_field)
322
+ return _file_get_inline(client, args, ref_field)
323
+
324
+
325
+ def _file_get_inline(client: RelayClient, args, ref_field: str) -> int:
326
+ payload = {ref_field: args.ref}
327
+ resp = client.submit_simple_task(
328
+ args.cap, payload, name=args.name or "", priority=args.priority,
329
+ owner_node_id=args.owner,
330
+ )
331
+ task_id = resp.get("task_id") or resp.get("id")
332
+ if not task_id:
333
+ print(f"no task_id in submit response: {resp}", file=sys.stderr)
334
+ return 1
335
+ result = _wait_for_result(client, str(task_id), args.interval)
336
+ if result is None:
337
+ return 1
338
+ data_b64 = result.get("data_base64")
339
+ if not data_b64:
340
+ # Maybe a bridge download_url slipped through — surface it.
341
+ if result.get("download_url"):
342
+ print(
343
+ "capability returned a download_url but bridge is not in "
344
+ "upload_modes; use `bridge download` instead",
345
+ file=sys.stderr,
346
+ )
347
+ return 1
348
+ print(f"no data_base64 in result: {result}", file=sys.stderr)
349
+ return 1
350
+ out_path = args.output or Path(
351
+ result.get("filename") or result.get("channel_id")
352
+ or result.get("backup_id") or "download"
353
+ )
354
+ try:
355
+ out_path.write_bytes(base64.b64decode(data_b64))
356
+ except Exception as exc: # noqa: BLE001
357
+ print(f"failed to write inline data: {exc}", file=sys.stderr)
358
+ return 1
359
+ _print_get_result(args, out_path, "inline")
360
+ return 0
361
+
362
+
363
+ def _file_get_bridge(client: RelayClient, args, ref_field: str) -> int:
364
+ """Open a bridge download channel and stream the file to disk."""
365
+ if ref_field == "backup_id":
366
+ payload = {"backup_id": args.ref}
367
+ else:
368
+ payload = {"channel_id": args.ref}
369
+ resp = client.submit_simple_task(
370
+ args.cap, payload, name=args.name or "", priority=args.priority,
371
+ owner_node_id=args.owner,
372
+ )
373
+ task_id = resp.get("task_id") or resp.get("id")
374
+ if not task_id:
375
+ print(f"no task_id in submit response: {resp}", file=sys.stderr)
376
+ return 1
377
+ result = _wait_for_result(client, str(task_id), args.interval)
378
+ if result is None:
379
+ return 1
380
+
381
+ out_path = args.output
382
+ # Inline fallback: small files may come back as data_base64.
383
+ if "data_base64" in result and not result.get("download_url"):
384
+ if out_path is None:
385
+ out_path = Path(
386
+ result.get("filename") or result.get("channel_id")
387
+ or result.get("backup_id") or "download"
388
+ )
389
+ try:
390
+ out_path.write_bytes(base64.b64decode(result["data_base64"]))
391
+ except Exception as exc: # noqa: BLE001
392
+ print(f"failed to write inline data: {exc}", file=sys.stderr)
393
+ return 1
394
+ _print_get_result(args, out_path, "inline")
395
+ return 0
396
+
397
+ url = result.get("download_url")
398
+ if not url:
399
+ print(f"no download_url in result: {result}", file=sys.stderr)
400
+ return 1
401
+ try:
402
+ with httpx.stream(
403
+ "GET", url, headers=_bearer(client),
404
+ timeout=httpx.Timeout(30.0, read=None),
405
+ ) as r:
406
+ r.raise_for_status()
407
+ if out_path is None:
408
+ header_name = r.headers.get("x-filename")
409
+ out_path = Path(
410
+ result.get("filename") or header_name
411
+ or result.get("channel_id")
412
+ or result.get("backup_id") or "download"
413
+ )
414
+ total = 0
415
+ with out_path.open("wb") as f:
416
+ for chunk in r.iter_bytes(chunk_size=_CHUNK):
417
+ f.write(chunk)
418
+ total += len(chunk)
419
+ except httpx.HTTPError as exc:
420
+ print(f"bridge download failed: {exc}", file=sys.stderr)
421
+ return 1
422
+ _print_get_result(args, out_path, "bridge")
423
+ return 0
424
+
425
+
426
+ def _print_get_result(args, out_path: Path, mode: str) -> None:
427
+ size = out_path.stat().st_size if out_path.exists() else 0
428
+ if args.json:
429
+ print(json.dumps(
430
+ {"status": "downloaded", "mode": mode, "path": str(out_path),
431
+ "size_bytes": size}, default=str,
432
+ ))
433
+ return
434
+ print(f"✅ Downloaded {size} bytes to {out_path} via {mode}")