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.
- iowap_node-2.3.20.dist-info/METADATA +126 -0
- iowap_node-2.3.20.dist-info/RECORD +28 -0
- iowap_node-2.3.20.dist-info/WHEEL +4 -0
- iowap_node-2.3.20.dist-info/entry_points.txt +3 -0
- iowap_node-2.3.20.dist-info/licenses/LICENSE +21 -0
- nodes/__init__.py +1 -0
- nodes/common/__init__.py +0 -0
- nodes/common/capability.py +520 -0
- nodes/common/cli/__init__.py +0 -0
- nodes/common/cli/cli_artifact.py +42 -0
- nodes/common/cli/cli_bridge.py +264 -0
- nodes/common/cli/cli_capabilities.py +229 -0
- nodes/common/cli/cli_docs.py +122 -0
- nodes/common/cli/cli_file.py +434 -0
- nodes/common/cli/cli_hp.py +546 -0
- nodes/common/cli/cli_node.py +249 -0
- nodes/common/cli/cli_route.py +92 -0
- nodes/common/cli/cli_server.py +248 -0
- nodes/common/cli/cli_task.py +191 -0
- nodes/common/cli/cli_update.py +48 -0
- nodes/common/envelope.py +168 -0
- nodes/common/file_serve.py +648 -0
- nodes/common/handler_runner.py +497 -0
- nodes/common/node_cli.py +1311 -0
- nodes/common/node_config.py +856 -0
- nodes/common/node_daemon.py +946 -0
- nodes/common/node_utils.py +448 -0
- nodes/common/relay_client.py +1246 -0
|
@@ -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}")
|