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,546 @@
1
+ """T-179: handler primitives — ``node-cli hp put/get``.
2
+
3
+ Scripts talk exclusively to the local node-cli (subprocess contract,
4
+ board decision ①): token, ladder negotiation and the transfer formats
5
+ stay inside the CLI process. Scripts see only a path in / a path out.
6
+
7
+ FROZEN CONTRACT (Phase 1, T-179)
8
+ ================================
9
+ Every public signature in this module is immutable across Phase 2
10
+ (scaffold: test stubs + parser boilerplate) and Phase 3 (integrate:
11
+ implementation). Specifically FROZEN:
12
+
13
+ * parameter names + order (the Phase 2/3 tests call them positionally
14
+ and by keyword — both must remain stable),
15
+ * return types (``int`` exit codes; ``0`` ok / ``1`` error / ``2`` usage),
16
+ * stdout contracts (exactly ONE compact JSON line each, see cmd_put/cmd_get),
17
+ * the error message strings of ``decide_mode`` (Task 2b needs them
18
+ byte-identical to ``cli_file._choose_mode``).
19
+
20
+ House convention (like cli_file/cli_task, T-117 split): CLI handlers are
21
+ ``(client, args) -> int`` and get the ``with_client(...)`` decorator at
22
+ parser registration time in ``node_cli.py``. This module does NOT import
23
+ ``node_cli`` (no circular import; the RelayClient monkeypatch stays
24
+ functional).
25
+
26
+ Phase 1 delivers only signatures + contract documentation; the bodies
27
+ are implemented in Phase 3 (Phase 2 produces ``NotImplementedError``
28
+ test stubs that are already prepared here).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import sys
37
+ from datetime import UTC, datetime, timedelta
38
+ from pathlib import Path
39
+ from typing import Any, TextIO
40
+
41
+ import httpx
42
+
43
+ from nodes.common import file_serve
44
+ from nodes.common.cli.cli_file import _load_capability_modes
45
+ from nodes.common.envelope import ( # noqa: F401
46
+ ENVELOPE_KEY,
47
+ EnvelopeError,
48
+ dump,
49
+ is_envelope,
50
+ make_envelope,
51
+ parse_envelope,
52
+ )
53
+ from nodes.common.relay_client import RelayClient
54
+
55
+ # Order = preference; the first allowed rung whose threshold fits.
56
+ # FROZEN: the order is part of the contract (inline < artifact < bridge).
57
+ _LADDER = ("inline", "artifact", "bridge")
58
+
59
+
60
+ def decide_mode(
61
+ size_bytes: int,
62
+ upload_modes: list[str],
63
+ thresholds: dict[str, int] | None = None,
64
+ force: str | None = None,
65
+ ) -> str:
66
+ """Pick the ladder rung for a file — the shared decision point.
67
+
68
+ FROZEN SIGNATURE (Phase 1) — Task 2b routes ``cli_file._choose_mode``
69
+ through this function; that is why the error messages are FROZEN
70
+ too (see below).
71
+
72
+ Semantics
73
+ ---------
74
+ * Ladder preference: the first rung of ``_LADDER`` that (a) is
75
+ allowed in ``upload_modes`` and (b) whose threshold carries the
76
+ size.
77
+ * ``thresholds``: keys ``max_inline_bytes`` / ``max_artifact_bytes``
78
+ (from the server, ``RelayClient.get_transfer_config()``).
79
+ - Missing key or ``None`` → the rung counts as **unlimited**
80
+ (the safety net is the server payload limit, not a silent
81
+ failure).
82
+ - Explicit ``0`` → rung **forbidden** (cli_file semantics when
83
+ the server response is missing).
84
+ - ``thresholds=None`` → all rungs unlimited → pure preference
85
+ choice according to ``upload_modes``.
86
+ * ``bridge`` has no threshold key (the server defines none) →
87
+ unlimited; chosen as soon as inline/artifact do not fit.
88
+ * ``force``: overrides the choice but must be in ``upload_modes``.
89
+ Checked BEFORE the size logic (like cli_file).
90
+
91
+ Errors (FROZEN, byte-identical to ``cli_file._choose_mode``)
92
+ ------------------------------------------------------------
93
+ Raise ``ValueError`` (library level; the CLI adapter converts it
94
+ to SystemExit/exit code):
95
+
96
+ * force not allowed::
97
+
98
+ --force {force!r} not supported by capability (upload_modes={upload_modes})
99
+
100
+ * no rung fits::
101
+
102
+ file too big: {size_bytes} bytes, capability supports only {upload_modes} (server ladder: inline<={max_inline}, artifact<={max_artifact})
103
+
104
+ with ``max_inline = (thresholds or {}).get("max_inline_bytes", 0)`` etc.
105
+
106
+ Task-2b mapping (FROZEN, behavior-identical) in ``cli_file.py``::
107
+
108
+ thresholds = {
109
+ "max_inline_bytes": int(transfer_cfg.get("max_inline_bytes", 0)),
110
+ "max_artifact_bytes": int(transfer_cfg.get("max_artifact_bytes", 0)),
111
+ }
112
+ try:
113
+ return decide_mode(size, upload_modes, thresholds, force=force)
114
+ except ValueError as exc:
115
+ raise SystemExit(str(exc))
116
+
117
+ (cli_file materializes the 0-defaults explicitly — this keeps the
118
+ "missing key = unlimited" behavior of decide_mode without any
119
+ effect on the cli_file path.)
120
+ """
121
+ if force is not None:
122
+ if force not in upload_modes:
123
+ raise ValueError(
124
+ f"--force {force!r} not supported by capability "
125
+ f"(upload_modes={upload_modes})"
126
+ )
127
+ return force
128
+
129
+ th = thresholds or {}
130
+ max_inline = th.get("max_inline_bytes")
131
+ max_artifact = th.get("max_artifact_bytes")
132
+
133
+ if "inline" in upload_modes and (max_inline is None or size_bytes <= max_inline):
134
+ return "inline"
135
+ if "artifact" in upload_modes and (
136
+ max_artifact is None or size_bytes <= max_artifact
137
+ ):
138
+ return "artifact"
139
+ if "bridge" in upload_modes:
140
+ return "bridge"
141
+ raise ValueError(
142
+ f"file too big: {size_bytes} bytes, capability supports only {upload_modes} "
143
+ f"(server ladder: inline<={(th or {}).get('max_inline_bytes', 0)}, "
144
+ f"artifact<={(th or {}).get('max_artifact_bytes', 0)})"
145
+ )
146
+
147
+
148
+ def _serve_count_from_env() -> int:
149
+ """``--serve-count`` equivalent from ``IOWAP_SERVE_COUNT`` (1–10, F6).
150
+
151
+ The FROZEN ``cmd_put`` signature (F9) has no ``serve_count`` param —
152
+ the F6 multi-download runs through the env (0/invalid/out-of-range →
153
+ 1, no WARNING spam for the normal case). A value ``< 1`` would fail
154
+ fast in ``stage_file`` anyway — here we normalize cleanly to 1.
155
+ """
156
+ raw = os.environ.get("IOWAP_SERVE_COUNT")
157
+ if not raw:
158
+ return 1
159
+ try:
160
+ value = int(raw)
161
+ except ValueError:
162
+ return 1
163
+ return value if 1 <= value <= 10 else 1
164
+
165
+
166
+ def cmd_put(
167
+ client: RelayClient,
168
+ *,
169
+ cap: str,
170
+ path: Path,
171
+ name: str | None = None,
172
+ out: TextIO | None = None,
173
+ ) -> int:
174
+ """``node-cli hp put <path> --cap <cap>`` → envelope JSON on stdout.
175
+
176
+ FROZEN SIGNATURE (Phase 1).
177
+
178
+ Contract
179
+ --------
180
+ * Loads the capability details via ``_load_capability_modes(client,
181
+ cap)`` (``upload_modes``; its ``SystemExit`` on HTTP/lookup errors
182
+ propagates — ``main()`` turns it into the exit code).
183
+ * Thresholds via ``client.get_transfer_config()``; extract defensively
184
+ (take only real ints, anything else = omit the key → the rung is
185
+ unlimited). HTTP errors here → ``return 1`` with a clear message.
186
+ * ``mode = decide_mode(size, upload_modes, thresholds)`` with
187
+ ``size = path.stat().st_size`` (do NOT read the entire file first —
188
+ artifact/bridge are meant to handle big files without a RAM peak;
189
+ inline reads the bytes only after the decision). ``ValueError``
190
+ from decide_mode → stderr + ``return 1``.
191
+ * inline → ``make_envelope(src="inline", filename=name or path.name,
192
+ data=<bytes>)``.
193
+ * artifact → ``client.upload_artifact(path, name=name or path.name)``;
194
+ a missing ``artifact_id`` in the response → stderr + ``return 1``;
195
+ otherwise ``make_envelope(src="artifact", ..., artifact_id=aid)``.
196
+ * bridge → ``raise NotImplementedError("bridge-put follows in Task
197
+ 3")`` (deliberate MVP boundary — the envelope can carry bridge, the
198
+ channel-open flow cannot; see plan T-179).
199
+ * stdout: EXACTLY ONE line — compact JSON of the envelope
200
+ (``envelope.dump``) + ``"\n"``. ``out=None`` → resolve
201
+ ``sys.stdout`` at CALL time (not import time!) so that pytest-capsys
202
+ works. No other stdout output; errors → stderr.
203
+ * Exit codes: ``0`` ok, ``1`` transfer/protocol error,
204
+ ``2`` usage (file missing — checked in dispatch_put).
205
+
206
+ ``name`` overrides the filename inside the envelope (default:
207
+ path.name).
208
+ """
209
+ if not path.is_file():
210
+ print(f"file not found: {path}", file=sys.stderr)
211
+ return 2
212
+
213
+ # 1. Capability details (upload_modes); SystemExit on lookup errors
214
+ # propagates — main() turns it into the exit code.
215
+ cap_detail = _load_capability_modes(client, cap)
216
+ upload_modes = list(
217
+ cap_detail.get("upload_modes") or ["inline", "artifact", "bridge"]
218
+ )
219
+
220
+ # 2. Load the server ladder config; defensively: take only real ints,
221
+ # anything else = omit the key → rung unlimited (F2.5 semantics).
222
+ try:
223
+ transfer_cfg = client.get_transfer_config()
224
+ except httpx.HTTPError as exc:
225
+ print(f"failed to load transfer config: {exc}", file=sys.stderr)
226
+ return 1
227
+ thresholds: dict[str, int] = {}
228
+ if isinstance(transfer_cfg, dict):
229
+ for key in ("max_inline_bytes", "max_artifact_bytes"):
230
+ value = transfer_cfg.get(key)
231
+ if isinstance(value, int) and not isinstance(value, bool):
232
+ thresholds[key] = value
233
+
234
+ # 3. Size via stat (F2.9) — read the bytes only after the decision.
235
+ size = path.stat().st_size
236
+ try:
237
+ mode = decide_mode(size, upload_modes, thresholds)
238
+ except ValueError as exc:
239
+ print(str(exc), file=sys.stderr)
240
+ return 1
241
+
242
+ # 4. Build the envelope per mode.
243
+ if mode == "inline":
244
+ envelope = make_envelope(
245
+ src="inline", filename=name or path.name, data=path.read_bytes()
246
+ )
247
+ elif mode == "artifact":
248
+ try:
249
+ resp = client.upload_artifact(path, name=name or path.name)
250
+ except httpx.HTTPError as exc:
251
+ print(f"artifact upload failed: {exc}", file=sys.stderr)
252
+ return 1
253
+ artifact_id = resp.get("artifact_id") if isinstance(resp, dict) else None
254
+ if not artifact_id:
255
+ print(
256
+ "artifact upload response without artifact_id", file=sys.stderr
257
+ )
258
+ return 1
259
+ envelope = make_envelope(
260
+ src="artifact",
261
+ filename=name or path.name,
262
+ artifact_id=str(artifact_id),
263
+ size_bytes=size,
264
+ )
265
+ else: # bridge — ephemeral serve handoff (T-166, F1/F5/F6/F8)
266
+ manifest = file_serve.read_manifest()
267
+ if manifest is None or not file_serve.probe_serve(
268
+ manifest["port"], host=manifest.get("host")
269
+ ):
270
+ print(
271
+ "hp put: ephemeral serve not reachable (is the node daemon "
272
+ "running?) — use artifact fallback",
273
+ file=sys.stderr,
274
+ )
275
+ return 1
276
+ transfer_id, _staged, sha_hex = file_serve.stage_file(
277
+ path, max_downloads=_serve_count_from_env()
278
+ )
279
+ route_path = f"/download/{transfer_id}"
280
+ # D8: advertise host from the manifest (the daemon writes the
281
+ # serve_host() address) — the proxy dials the address VERBATIM,
282
+ # so it must be resolvable from the relay machine, not only from
283
+ # here (live: LXC 903 ↔ Hermes host).
284
+ upstream = (
285
+ f"http://{manifest.get('host', file_serve.SERVE_HOST)}:{manifest['port']}"
286
+ f"/transfer/{transfer_id}"
287
+ )
288
+ node_id = str((client.meta or {}).get("node_id") or "unknown")
289
+ expires = (
290
+ datetime.now(UTC) + timedelta(seconds=3600)
291
+ ).isoformat()
292
+ try:
293
+ client.register_temp_route(
294
+ route_path,
295
+ "POST",
296
+ upstream,
297
+ ttl_seconds=3600,
298
+ channel_id=transfer_id,
299
+ description="iowap ephemeral file serve (T-166)",
300
+ )
301
+ except httpx.HTTPError as exc:
302
+ # No stale left-over without a route (F6): staged file + manifest gone.
303
+ file_serve.discard_staged(transfer_id)
304
+ print(f"hp put: route registration failed: {exc}", file=sys.stderr)
305
+ return 1
306
+ envelope = make_envelope(
307
+ src="bridge",
308
+ filename=name or path.name,
309
+ storage_ref=file_serve.build_storage_ref(node_id, route_path, expires),
310
+ sha256=sha_hex,
311
+ size_bytes=size, # read before stage_file — move semantics (F5)
312
+ )
313
+
314
+ # 5. stdout: EXACTLY ONE compact JSON line; out=None → sys.stdout at
315
+ # CALL time (capsys-compatible).
316
+ stream = out if out is not None else sys.stdout
317
+ stream.write(dump(envelope) + "\n")
318
+ return 0
319
+
320
+
321
+ def cmd_get(
322
+ client: RelayClient,
323
+ *,
324
+ envelope: dict[str, Any],
325
+ out_dir: Path,
326
+ output: Path | None = None,
327
+ out: TextIO | None = None,
328
+ ) -> int:
329
+ """``node-cli hp get`` → resolve the envelope, store the file locally.
330
+
331
+ FROZEN SIGNATURE (Phase 1).
332
+
333
+ Contract
334
+ --------
335
+ * ``parse_envelope(envelope)`` → ``(src, ref)``; ``EnvelopeError`` →
336
+ ``"hp get: {exc}"`` on stderr + ``return 1``.
337
+ * Filename: ``envelope["__iowap_ref__"].get("filename") or
338
+ "download"`` (the filename from the envelope is NOT trustworthy
339
+ as a path — do not accept ``..``/absolute paths, sanitize).
340
+ * ``out_dir`` is created when needed (``mkdir(parents=True,
341
+ exist_ok=True)``) — also for the ``output`` override, so the
342
+ default-path branch stays consistent.
343
+ * inline → ``target = output or out_dir / filename``;
344
+ ``target.write_bytes(ref)``.
345
+ * artifact → ``target = client.download_artifact(ref, output or
346
+ out_dir / filename)`` (RelayClient takes care of the streaming).
347
+ * bridge → stderr ``"hp get: bridge resolution not supported by
348
+ this node-cli version"`` + ``return 1`` (MVP boundary; the exact
349
+ message string is FROZEN — plan T-179 Task 3).
350
+ * sha256 verification when the envelope carries one (inline always
351
+ does): mismatch → stderr
352
+ ``"hp get: sha256 mismatch ({digest} != {expected})"`` + ``return 1``.
353
+ * stdout: EXACTLY ONE line — compact JSON
354
+ ``{"path": str(target), "size_bytes": target.stat().st_size,
355
+ "src": <mode>}`` + ``"\n"`` (keys FROZEN). ``out=None`` →
356
+ ``sys.stdout`` at CALL time (capsys-compatible).
357
+ * Exit codes: ``0`` ok, ``1`` resolution/verification error,
358
+ ``2`` usage.
359
+ """
360
+ try:
361
+ src, ref = parse_envelope(envelope)
362
+ except EnvelopeError as exc:
363
+ print(f"hp get: {exc}", file=sys.stderr)
364
+ return 1
365
+
366
+ # The filename from the envelope is NOT trustworthy (no
367
+ # ../absolute paths) — sanitize.
368
+ raw_name = (envelope.get(ENVELOPE_KEY) or {}).get("filename")
369
+ if not isinstance(raw_name, str) or not raw_name:
370
+ raw_name = "download"
371
+ safe_name = Path(raw_name).name
372
+ if safe_name in ("", ".", ".."):
373
+ safe_name = "download"
374
+
375
+ out_dir.mkdir(parents=True, exist_ok=True)
376
+
377
+ if src == "bridge":
378
+ serve_ref = ref if isinstance(ref, dict) else {}
379
+ if (
380
+ serve_ref.get("type") != "node_serve"
381
+ or not serve_ref.get("node_id")
382
+ or not serve_ref.get("path")
383
+ ):
384
+ print(
385
+ "hp get: bridge storage_ref missing node_serve fields "
386
+ "(node_id/path)",
387
+ file=sys.stderr,
388
+ )
389
+ return 1
390
+ url = file_serve.proxy_url(
391
+ client.base_url, str(serve_ref["node_id"]), str(serve_ref["path"])
392
+ )
393
+ target = output or out_dir / safe_name
394
+ headers: dict[str, str] = {}
395
+ if client.token:
396
+ headers["Authorization"] = f"Bearer {client.token}"
397
+ try:
398
+ # POST with an empty body — the proxy matches the registered
399
+ # method exactly (route_registry.py:194-200); GET on a POST
400
+ # route would be 404. The serve handler ignores the body.
401
+ with httpx.stream(
402
+ "POST",
403
+ url,
404
+ headers=headers,
405
+ content=b"",
406
+ timeout=httpx.Timeout(30.0, read=None),
407
+ ) as r:
408
+ r.raise_for_status()
409
+ with target.open("wb") as f:
410
+ for chunk in r.iter_bytes(chunk_size=64 * 1024):
411
+ f.write(chunk)
412
+ except httpx.HTTPError as exc:
413
+ print(f"hp get: bridge pull failed: {exc}", file=sys.stderr)
414
+ try:
415
+ target.unlink(missing_ok=True)
416
+ except OSError:
417
+ pass
418
+ return 1
419
+ # F7: the receiver NEVER unregisters (it is not the owner) —
420
+ # ephemerality/TTL is the sender daemon's job.
421
+
422
+ elif src == "inline":
423
+ target = output or out_dir / safe_name
424
+ target.write_bytes(ref)
425
+ else: # artifact — RelayClient takes care of the streaming.
426
+ try:
427
+ target = client.download_artifact(ref, output or out_dir / safe_name)
428
+ except httpx.HTTPError as exc:
429
+ print(f"hp get: artifact download failed: {exc}", file=sys.stderr)
430
+ return 1
431
+
432
+ # sha256 verification when the envelope carries one (inline always does).
433
+ expected = (envelope.get(ENVELOPE_KEY) or {}).get("sha256")
434
+ if expected:
435
+ # T-192 (F5): hash chunkwise (64 KiB) — no RAM peak on the
436
+ # big bridge/artifact path, which the rest of the flow streams
437
+ # deliberately.
438
+ digest = file_serve._sha256_file(target)
439
+ if digest != expected:
440
+ print(
441
+ f"hp get: sha256 mismatch ({digest} != {expected})",
442
+ file=sys.stderr,
443
+ )
444
+ try:
445
+ target.unlink()
446
+ except OSError:
447
+ pass
448
+ return 1
449
+
450
+ stream = out if out is not None else sys.stdout
451
+ stream.write(
452
+ json.dumps(
453
+ {
454
+ "path": str(target),
455
+ "size_bytes": target.stat().st_size,
456
+ "src": src,
457
+ },
458
+ separators=(",", ":"),
459
+ )
460
+ + "\n"
461
+ )
462
+ return 0
463
+
464
+
465
+ def default_out_dir() -> Path:
466
+ """Default drop directory for ``hp get``: ``~/.relay/tmp/<task_id>/``.
467
+
468
+ FROZEN SIGNATURE (Phase 1). Task ID from ``RELAY_TASK_ID`` (assigned
469
+ by the handler_runner to every handler, see
470
+ ``handler_runner.HANDLER_ENV_KEYS``); without handler context →
471
+ ``adhoc``. Mirrors ``node_config.BASE_DIR`` (``~/.relay``) — the same
472
+ root as profile/meta, no new root folder.
473
+ """
474
+ task_id = __import__("os").environ.get("RELAY_TASK_ID") or "adhoc"
475
+ return Path.home() / ".relay" / "tmp" / task_id
476
+
477
+
478
+ def dispatch_put(client: RelayClient, args: argparse.Namespace) -> int:
479
+ """Argparse adapter: ``node-cli hp put <path> --cap <cap> [--name N]``.
480
+
481
+ FROZEN SIGNATURE (Phase 1). House convention ``(client, args) -> int``;
482
+ registration in ``node_cli.py`` (Phase 2)::
483
+
484
+ p_hp_put.set_defaults(func=with_client(cli_hp.dispatch_put))
485
+
486
+ Namespace attributes (FROZEN, Phase 2 registers exactly these):
487
+
488
+ * ``args.path`` (str, positional) — file, existence-checked here:
489
+ missing → stderr + ``return 2`` (like cli_file._cmd_file_send).
490
+ * ``args.cap`` (str, --cap, required)
491
+ * ``args.name`` (str | None, --name, default None)
492
+
493
+ Delegates to ``cmd_put(client, cap=args.cap, path=Path(args.path),
494
+ name=args.name)``.
495
+ """
496
+ path = Path(args.path)
497
+ if not path.is_file():
498
+ print(f"file not found: {path}", file=sys.stderr)
499
+ return 2
500
+ return cmd_put(client, cap=args.cap, path=path, name=args.name)
501
+
502
+
503
+ def dispatch_get(client: RelayClient, args: argparse.Namespace) -> int:
504
+ """Argparse adapter: ``node-cli hp get [--file F] [--output P]``.
505
+
506
+ FROZEN SIGNATURE (Phase 1). House convention ``(client, args) -> int``;
507
+ registration in ``node_cli.py`` (Phase 2)::
508
+
509
+ p_hp_get.set_defaults(func=with_client(cli_hp.dispatch_get))
510
+
511
+ Namespace attributes (FROZEN, Phase 2 registers exactly these):
512
+
513
+ * ``args.file`` (str | None, --file, default None) — envelope JSON
514
+ as a file; without --file stdin is read (``sys.stdin``, call time).
515
+ * ``args.output`` (Path | None, --output/-o, default None)
516
+ * ``args.out_dir`` (Path | None, --out-dir, default None) — override,
517
+ otherwise ``default_out_dir()``.
518
+
519
+ The input must be a JSON object with ``__iowap_ref__``; invalid
520
+ JSON → stderr + ``return 2`` (usage). Delegates to ``cmd_get``.
521
+ """
522
+ if args.file:
523
+ try:
524
+ raw = Path(args.file).read_text(encoding="utf-8")
525
+ except OSError as exc:
526
+ print(f"hp get: cannot read {args.file}: {exc}", file=sys.stderr)
527
+ return 2
528
+ else:
529
+ raw = sys.stdin.read()
530
+
531
+ try:
532
+ envelope = json.loads(raw)
533
+ except json.JSONDecodeError as exc:
534
+ print(f"hp get: invalid JSON: {exc}", file=sys.stderr)
535
+ return 2
536
+ if not isinstance(envelope, dict) or ENVELOPE_KEY not in envelope:
537
+ print(
538
+ f"hp get: no {ENVELOPE_KEY} key — not an envelope",
539
+ file=sys.stderr,
540
+ )
541
+ return 2
542
+
543
+ out_dir = args.out_dir if args.out_dir is not None else default_out_dir()
544
+ return cmd_get(
545
+ client, envelope=envelope, out_dir=out_dir, output=args.output
546
+ )