contree-cli 0.7.1__tar.gz → 0.8.0__tar.gz
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.
- {contree_cli-0.7.1 → contree_cli-0.8.0}/PKG-INFO +1 -1
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/operation.py +14 -1
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/run.py +264 -15
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/show.py +1 -1
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/client.py +215 -38
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_run.py +30 -18
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/url_fetch.py +13 -48
- {contree_cli-0.7.1 → contree_cli-0.8.0}/pyproject.toml +2 -1
- {contree_cli-0.7.1 → contree_cli-0.8.0}/LICENSE +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/README.md +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/__init__.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/__main__.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/agent.md +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/arguments.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/__init__.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/agent.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/auth.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/build.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/cat.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/cd.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/cp.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/env.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/file.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/images.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/ls.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/session.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/skill.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/tag.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/use.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/config.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/__init__.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/context.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/dockerignore.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/keyword.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_add.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_arg.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_copy.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_env.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_from.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_skipped.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_user.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_workdir.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/local_context.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/parser.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/log.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/man.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/manual.md +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/mapped_file.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/migrations.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/output.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/py.typed +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/refs.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/session.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/__init__.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/argmap.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/argspec.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/cache.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/completer.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/history.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/parser.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/repl.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/sources.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/trie.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/skill.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/skill_body.md +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/types.py +0 -0
- {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/update_check.py +0 -0
|
@@ -13,6 +13,7 @@ Subcommands:
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import argparse
|
|
16
|
+
import contextlib
|
|
16
17
|
import itertools
|
|
17
18
|
import json
|
|
18
19
|
import logging
|
|
@@ -21,7 +22,7 @@ from dataclasses import dataclass, field
|
|
|
21
22
|
from datetime import datetime
|
|
22
23
|
from typing import Any
|
|
23
24
|
|
|
24
|
-
from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
|
|
25
|
+
from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
|
|
25
26
|
from contree_cli.cli.show import ShowArgs, cmd_show
|
|
26
27
|
from contree_cli.client import ApiError, ContreeClient, PaginatedFetcher
|
|
27
28
|
from contree_cli.output import OutputFormatter
|
|
@@ -496,11 +497,23 @@ def cmd_wait(args: WaitArgs) -> int | None:
|
|
|
496
497
|
exit_status = 0
|
|
497
498
|
sleep_time = 0.5
|
|
498
499
|
|
|
500
|
+
# Op cache is best-effort — `wait` doesn't strictly need the
|
|
501
|
+
# session store, and the cmd is reachable from contexts that don't
|
|
502
|
+
# set one (e.g. standalone scripts).
|
|
503
|
+
try:
|
|
504
|
+
store = SESSION_STORE.get()
|
|
505
|
+
except LookupError:
|
|
506
|
+
store = None
|
|
507
|
+
|
|
499
508
|
while pending and time.monotonic() < deadline:
|
|
500
509
|
for uuid in list(pending):
|
|
501
510
|
resp = client.get(f"/v1/operations/{uuid}")
|
|
502
511
|
op = json.loads(resp.read())
|
|
512
|
+
|
|
503
513
|
if op.get("status") in TERMINAL_STATUSES:
|
|
514
|
+
if store is not None:
|
|
515
|
+
with contextlib.suppress(Exception):
|
|
516
|
+
store.cache[(uuid, "operation")] = op
|
|
504
517
|
pending.discard(uuid)
|
|
505
518
|
exit_code = extract_exit_code(op)
|
|
506
519
|
formatter(
|
|
@@ -48,6 +48,7 @@ from __future__ import annotations
|
|
|
48
48
|
|
|
49
49
|
import argparse
|
|
50
50
|
import base64
|
|
51
|
+
import contextlib
|
|
51
52
|
import fnmatch
|
|
52
53
|
import functools
|
|
53
54
|
import io
|
|
@@ -62,9 +63,18 @@ import time
|
|
|
62
63
|
import uuid
|
|
63
64
|
from dataclasses import dataclass, field
|
|
64
65
|
from multiprocessing.pool import ThreadPool
|
|
66
|
+
from typing import Any
|
|
65
67
|
|
|
66
68
|
from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
|
|
67
|
-
from contree_cli.client import
|
|
69
|
+
from contree_cli.client import (
|
|
70
|
+
RETRYABLE_NETWORK_ERRORS,
|
|
71
|
+
ApiError,
|
|
72
|
+
ContreeClient,
|
|
73
|
+
decode_event_chunk,
|
|
74
|
+
decode_stream,
|
|
75
|
+
iter_sse_events,
|
|
76
|
+
resolve_image,
|
|
77
|
+
)
|
|
68
78
|
from contree_cli.mapped_file import MAPPING_RULES, MappedFile
|
|
69
79
|
from contree_cli.output import (
|
|
70
80
|
DefaultFormatter,
|
|
@@ -505,11 +515,227 @@ def _build_payload(
|
|
|
505
515
|
return payload
|
|
506
516
|
|
|
507
517
|
|
|
518
|
+
TERMINAL_OP_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@dataclass
|
|
522
|
+
class TerminalSummary:
|
|
523
|
+
"""Authoritative end-of-stream snapshot built from the SSE events
|
|
524
|
+
themselves — `cmd_run` consumes this directly instead of doing a
|
|
525
|
+
second ``GET /operations/{uuid}``.
|
|
526
|
+
|
|
527
|
+
If SSE ends without a `completion` frame but a between-attempt GET
|
|
528
|
+
detects the op is already terminal, the full op dict lands in
|
|
529
|
+
``fallback_op`` so `cmd_run` can use it directly."""
|
|
530
|
+
|
|
531
|
+
completion: dict[str, Any] | None = None
|
|
532
|
+
exit_event: dict[str, Any] | None = None
|
|
533
|
+
stdout: bytearray = field(default_factory=bytearray)
|
|
534
|
+
stderr: bytearray = field(default_factory=bytearray)
|
|
535
|
+
fallback_op: dict[str, Any] | None = None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _check_terminal_via_get(
|
|
539
|
+
client: ContreeClient, op_uuid: str, summary: TerminalSummary
|
|
540
|
+
) -> bool:
|
|
541
|
+
"""Poll `GET /v1/operations/{uuid}`; if the op is in a terminal
|
|
542
|
+
status, park the full response on ``summary.fallback_op`` and
|
|
543
|
+
return True. Any GET failure or non-terminal status returns False
|
|
544
|
+
so the caller keeps retrying SSE."""
|
|
545
|
+
try:
|
|
546
|
+
resp = client.request("GET", f"/v1/operations/{op_uuid}")
|
|
547
|
+
op = json.loads(resp.read())
|
|
548
|
+
except (ApiError, ValueError) as exc:
|
|
549
|
+
logger.debug("terminal check GET failed: %s", exc)
|
|
550
|
+
return False
|
|
551
|
+
except RETRYABLE_NETWORK_ERRORS as exc:
|
|
552
|
+
logger.debug("terminal check GET failed: %s", exc)
|
|
553
|
+
return False
|
|
554
|
+
if isinstance(op, dict) and op.get("status") in TERMINAL_OP_STATUSES:
|
|
555
|
+
summary.fallback_op = op
|
|
556
|
+
return True
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
TIGHT_LOOP_FLOOR = 0.5
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _stream_events_until_close(
|
|
564
|
+
client: ContreeClient,
|
|
565
|
+
op_uuid: str,
|
|
566
|
+
formatter: OutputFormatter,
|
|
567
|
+
) -> TerminalSummary:
|
|
568
|
+
"""Open `follow=1` SSE for *op_uuid* and write events to stdio,
|
|
569
|
+
transparently resuming on network drops / mid-stream errors using
|
|
570
|
+
``Last-Event-Id`` for replay-free continuation.
|
|
571
|
+
|
|
572
|
+
For ``DefaultFormatter``: ``stdout`` / ``stderr`` events go to
|
|
573
|
+
``sys.std*.buffer`` directly so the user sees output as it arrives.
|
|
574
|
+
For JSON formatters: data goes to ``log.debug`` so the JSON output
|
|
575
|
+
isn't polluted, but the chunks are accumulated into the returned
|
|
576
|
+
``TerminalSummary`` so the caller can render full stdout/stderr
|
|
577
|
+
without a follow-up GET.
|
|
578
|
+
|
|
579
|
+
Loops until one of: `completion` event received, GET-detected
|
|
580
|
+
terminal status, ``BrokenPipeError`` from a local stdio write, or
|
|
581
|
+
``KeyboardInterrupt``. Backoff between attempts is delegated to
|
|
582
|
+
``client.request`` — every SSE reconnect goes through its
|
|
583
|
+
``RETRY_DELAYS`` ladder before raising, so an extra streamer-level
|
|
584
|
+
ramp would double up. The one floor kept here (``TIGHT_LOOP_FLOOR``)
|
|
585
|
+
only kicks in when a cycle made no forward progress, guarding
|
|
586
|
+
against a server that returns immediate empty streams for an
|
|
587
|
+
executing op.
|
|
588
|
+
|
|
589
|
+
``BrokenPipeError`` from a local stdio write propagates unchanged —
|
|
590
|
+
it means the shell pipe closed and retrying cannot help; the
|
|
591
|
+
caller cancels the op and exits.
|
|
592
|
+
"""
|
|
593
|
+
is_default = isinstance(formatter, DefaultFormatter)
|
|
594
|
+
last_id: int = -1
|
|
595
|
+
summary = TerminalSummary()
|
|
596
|
+
|
|
597
|
+
while True:
|
|
598
|
+
headers: dict[str, str] | None = None
|
|
599
|
+
if last_id >= 0:
|
|
600
|
+
headers = {"Last-Event-Id": str(last_id)}
|
|
601
|
+
try:
|
|
602
|
+
resp = client.request(
|
|
603
|
+
"GET",
|
|
604
|
+
f"/v1/operations/{op_uuid}/events?follow=1",
|
|
605
|
+
headers=headers,
|
|
606
|
+
)
|
|
607
|
+
except ApiError as exc:
|
|
608
|
+
logger.debug("SSE connect failed after client retries: %s", exc)
|
|
609
|
+
if _check_terminal_via_get(client, op_uuid, summary):
|
|
610
|
+
return summary
|
|
611
|
+
continue
|
|
612
|
+
except RETRYABLE_NETWORK_ERRORS as exc:
|
|
613
|
+
logger.debug("SSE connect network error after client retries: %s", exc)
|
|
614
|
+
if _check_terminal_via_get(client, op_uuid, summary):
|
|
615
|
+
return summary
|
|
616
|
+
continue
|
|
617
|
+
|
|
618
|
+
events_before = last_id
|
|
619
|
+
try:
|
|
620
|
+
for ev in iter_sse_events(resp):
|
|
621
|
+
ev_id = ev.get("id")
|
|
622
|
+
if isinstance(ev_id, int):
|
|
623
|
+
last_id = ev_id
|
|
624
|
+
ev_type = ev.get("type")
|
|
625
|
+
data = ev.get("data")
|
|
626
|
+
match ev_type:
|
|
627
|
+
case "stdout":
|
|
628
|
+
chunk = decode_event_chunk(data)
|
|
629
|
+
summary.stdout.extend(chunk)
|
|
630
|
+
if is_default:
|
|
631
|
+
sys.stdout.buffer.write(chunk)
|
|
632
|
+
sys.stdout.buffer.flush()
|
|
633
|
+
case "stderr":
|
|
634
|
+
chunk = decode_event_chunk(data)
|
|
635
|
+
summary.stderr.extend(chunk)
|
|
636
|
+
if is_default:
|
|
637
|
+
sys.stderr.buffer.write(chunk)
|
|
638
|
+
sys.stderr.buffer.flush()
|
|
639
|
+
case "exit":
|
|
640
|
+
# spid=1 is the main process — its exit code/timed_out
|
|
641
|
+
# drive the CLI's own exit code.
|
|
642
|
+
if ev.get("spid") == 1:
|
|
643
|
+
summary.exit_event = ev
|
|
644
|
+
logger.debug("event: %s", ev)
|
|
645
|
+
case "sse_error":
|
|
646
|
+
logger.warning(
|
|
647
|
+
"server-side stream error (last_id=%s): %s",
|
|
648
|
+
last_id,
|
|
649
|
+
ev.get("message"),
|
|
650
|
+
)
|
|
651
|
+
case "completion":
|
|
652
|
+
# Authoritative terminal frame — don't wait for the server
|
|
653
|
+
# to close, return the summary for the caller.
|
|
654
|
+
summary.completion = ev
|
|
655
|
+
logger.debug("event: %s", ev)
|
|
656
|
+
return summary
|
|
657
|
+
case _:
|
|
658
|
+
logger.debug("event: %s", ev)
|
|
659
|
+
except BrokenPipeError:
|
|
660
|
+
# Local stdout/stderr was closed by the shell (e.g. piping
|
|
661
|
+
# into `head`). Retrying cannot help — the caller cancels
|
|
662
|
+
# the op and exits.
|
|
663
|
+
raise
|
|
664
|
+
except RETRYABLE_NETWORK_ERRORS as exc:
|
|
665
|
+
logger.debug("SSE stream broken (last_id=%s): %s", last_id, exc)
|
|
666
|
+
finally:
|
|
667
|
+
with contextlib.suppress(Exception):
|
|
668
|
+
resp.close()
|
|
669
|
+
|
|
670
|
+
if _check_terminal_via_get(client, op_uuid, summary):
|
|
671
|
+
return summary
|
|
672
|
+
|
|
673
|
+
# No forward progress this cycle → briefly floor the loop so a
|
|
674
|
+
# server that keeps returning immediate EOFs doesn't spin us.
|
|
675
|
+
if last_id == events_before:
|
|
676
|
+
time.sleep(TIGHT_LOOP_FLOOR)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _build_op_from_summary(op_uuid: str, summary: TerminalSummary) -> dict[str, Any]:
|
|
680
|
+
"""Synthesize a `GET /operations/{uuid}` shape from the SSE terminal
|
|
681
|
+
summary — completion event drives status / error / duration / image
|
|
682
|
+
metadata; exit event drives exit_code + timed_out; stdout/stderr
|
|
683
|
+
are reassembled from the streamed chunks.
|
|
684
|
+
|
|
685
|
+
Lets `cmd_run` avoid a second HTTP round-trip on the happy path
|
|
686
|
+
while keeping the downstream `_display_operation` consumers
|
|
687
|
+
(default and JSON formatters) untouched."""
|
|
688
|
+
assert summary.completion is not None
|
|
689
|
+
completion_data = summary.completion.get("data") or {}
|
|
690
|
+
status = completion_data.get("status")
|
|
691
|
+
state: dict[str, Any] = {}
|
|
692
|
+
if summary.exit_event:
|
|
693
|
+
exit_data = summary.exit_event.get("data") or {}
|
|
694
|
+
if "code" in exit_data:
|
|
695
|
+
state["exit_code"] = int(exit_data["code"])
|
|
696
|
+
if "timed_out" in exit_data:
|
|
697
|
+
state["timed_out"] = bool(exit_data["timed_out"])
|
|
698
|
+
result_image_uuid = completion_data.get("result_image_uuid")
|
|
699
|
+
return {
|
|
700
|
+
"uuid": op_uuid,
|
|
701
|
+
"kind": "instance",
|
|
702
|
+
"status": status,
|
|
703
|
+
"error": completion_data.get("error"),
|
|
704
|
+
"duration": (completion_data.get("duration_ms") or 0) / 1000.0,
|
|
705
|
+
"image_size": completion_data.get("image_size_bytes"),
|
|
706
|
+
"result_image_uuid": result_image_uuid,
|
|
707
|
+
"metadata": {
|
|
708
|
+
"result": {
|
|
709
|
+
"stdout": {
|
|
710
|
+
"value": bytes(summary.stdout).decode("utf-8", errors="replace"),
|
|
711
|
+
"encoding": "ascii",
|
|
712
|
+
"truncated": False,
|
|
713
|
+
},
|
|
714
|
+
"stderr": {
|
|
715
|
+
"value": bytes(summary.stderr).decode("utf-8", errors="replace"),
|
|
716
|
+
"encoding": "ascii",
|
|
717
|
+
"truncated": False,
|
|
718
|
+
},
|
|
719
|
+
"state": state or None,
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
"result": {"image": result_image_uuid, "tag": None},
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
|
|
508
726
|
def _display_operation(
|
|
509
727
|
op: dict[str, object],
|
|
510
728
|
formatter: OutputFormatter,
|
|
729
|
+
live_streamed: bool = False,
|
|
511
730
|
) -> None:
|
|
512
|
-
"""Display an operation result using the given formatter.
|
|
731
|
+
"""Display an operation result using the given formatter.
|
|
732
|
+
|
|
733
|
+
``live_streamed=True`` means the SSE path already wrote stdout/
|
|
734
|
+
stderr to stdio incrementally — DefaultFormatter then has nothing
|
|
735
|
+
left to print. ``live_streamed=False`` is the GET-fallback path
|
|
736
|
+
(legacy server or aborted stream) — DefaultFormatter prints
|
|
737
|
+
sanitized stdout/stderr from the operation's metadata blob.
|
|
738
|
+
"""
|
|
513
739
|
result = op.get("result") or {}
|
|
514
740
|
assert isinstance(result, dict)
|
|
515
741
|
metadata = op.get("metadata") or {}
|
|
@@ -544,10 +770,14 @@ def _display_operation(
|
|
|
544
770
|
"only json/json-pretty/default are supported"
|
|
545
771
|
)
|
|
546
772
|
|
|
547
|
-
|
|
773
|
+
if live_streamed:
|
|
774
|
+
# stdout/stderr already streamed live via SSE — nothing more to print.
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
# Legacy / fallback path (no completion event received): print
|
|
778
|
+
# sanitized stdout/stderr from op metadata.
|
|
548
779
|
stdout = _BREAKING_ESC_RE.sub("", decode_stream(instance_result.get("stdout")))
|
|
549
780
|
stderr = _BREAKING_ESC_RE.sub("", decode_stream(instance_result.get("stderr")))
|
|
550
|
-
|
|
551
781
|
if stdout:
|
|
552
782
|
sys.stdout.write(stdout)
|
|
553
783
|
if not stdout.endswith("\n"):
|
|
@@ -688,19 +918,27 @@ def cmd_run(args: RunArgs) -> int | None:
|
|
|
688
918
|
formatter.flush()
|
|
689
919
|
return None
|
|
690
920
|
|
|
691
|
-
# 5.
|
|
692
|
-
|
|
921
|
+
# 5. Stream events (follow=1) — write stdout/stderr to stdtio as they
|
|
922
|
+
# come, log other events at debug, accumulate the terminal frame.
|
|
923
|
+
store = SESSION_STORE.get()
|
|
693
924
|
try:
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
925
|
+
summary = _stream_events_until_close(client, op_uuid, formatter)
|
|
926
|
+
if summary.completion is not None:
|
|
927
|
+
# Authoritative terminal frame from the server — build the
|
|
928
|
+
# full op dict from the SSE events themselves, no GET.
|
|
929
|
+
op = _build_op_from_summary(op_uuid, summary)
|
|
930
|
+
elif summary.fallback_op is not None:
|
|
931
|
+
# SSE couldn't deliver `completion`, but a GET between
|
|
932
|
+
# retries confirmed the op is terminal — use that dict.
|
|
933
|
+
op = summary.fallback_op
|
|
934
|
+
else:
|
|
935
|
+
# Safety net: streamer normally loops until either
|
|
936
|
+
# completion or a terminal GET, so this path is only hit
|
|
937
|
+
# in tests where the stub queue drains early.
|
|
697
938
|
resp = client.get(f"/v1/operations/{op_uuid}")
|
|
698
939
|
op = json.loads(resp.read())
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
time.sleep(sleep_time)
|
|
702
|
-
if sleep_time < 5:
|
|
703
|
-
sleep_time += sleep_time
|
|
940
|
+
cache_key = (op_uuid, "operation")
|
|
941
|
+
store.cache[cache_key] = op
|
|
704
942
|
except KeyboardInterrupt:
|
|
705
943
|
try:
|
|
706
944
|
client.delete(f"/v1/operations/{op_uuid}")
|
|
@@ -708,6 +946,17 @@ def cmd_run(args: RunArgs) -> int | None:
|
|
|
708
946
|
except (ApiError, KeyboardInterrupt, OSError):
|
|
709
947
|
pass
|
|
710
948
|
raise
|
|
949
|
+
except BrokenPipeError:
|
|
950
|
+
# Local stdout/stderr was closed (e.g. `contree run | head`).
|
|
951
|
+
# Cancel the op, silence further stdio writes, then exit 141
|
|
952
|
+
# so callers see the SIGPIPE convention (128 + 13).
|
|
953
|
+
with contextlib.suppress(ApiError, OSError):
|
|
954
|
+
client.delete(f"/v1/operations/{op_uuid}")
|
|
955
|
+
with contextlib.suppress(OSError):
|
|
956
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
957
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
958
|
+
os.close(devnull)
|
|
959
|
+
raise SystemExit(141) from None
|
|
711
960
|
|
|
712
961
|
# 6. Cache terminal operation result
|
|
713
962
|
store.cache[(op_uuid, "operation")] = op
|
|
@@ -735,7 +984,7 @@ def cmd_run(args: RunArgs) -> int | None:
|
|
|
735
984
|
)
|
|
736
985
|
|
|
737
986
|
# 7. Display result
|
|
738
|
-
_display_operation(op, formatter)
|
|
987
|
+
_display_operation(op, formatter, live_streamed=summary.completion is not None)
|
|
739
988
|
|
|
740
989
|
result = op.get("result") or {}
|
|
741
990
|
assert isinstance(result, dict)
|
|
@@ -61,7 +61,7 @@ def cmd_show(args: ShowArgs) -> int | None:
|
|
|
61
61
|
|
|
62
62
|
cache_key = (op_uuid, "operation")
|
|
63
63
|
cached = store.cache.get(cache_key)
|
|
64
|
-
if cached
|
|
64
|
+
if isinstance(cached, dict) and cached.get("status") in TERMINAL:
|
|
65
65
|
op = cast(dict[str, Any], cached)
|
|
66
66
|
else:
|
|
67
67
|
resp = client.get(f"/v1/operations/{op_uuid}")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
import binascii
|
|
4
5
|
import collections
|
|
5
6
|
import contextlib
|
|
6
7
|
import http.client
|
|
@@ -12,10 +13,12 @@ import socket
|
|
|
12
13
|
import sys
|
|
13
14
|
import threading
|
|
14
15
|
import time
|
|
16
|
+
import zlib
|
|
15
17
|
from abc import ABC, abstractmethod
|
|
16
18
|
from collections.abc import Callable, Iterable, Iterator
|
|
17
19
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
18
|
-
from
|
|
20
|
+
from contextlib import suppress
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
19
22
|
from typing import IO, Any, cast
|
|
20
23
|
from urllib.parse import urlencode, urlsplit
|
|
21
24
|
|
|
@@ -23,7 +26,33 @@ from contree_cli.config import AuthType, ConfigProfile
|
|
|
23
26
|
|
|
24
27
|
log = logging.getLogger(__name__)
|
|
25
28
|
|
|
26
|
-
RETRY_DELAYS = (1, 2,
|
|
29
|
+
RETRY_DELAYS = (0.1, 0.2, 0.5, 1, 2, 5)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def retry_generator() -> Iterator[float]:
|
|
33
|
+
"""Return a primed generator that yields successive backoff delays.
|
|
34
|
+
|
|
35
|
+
The first ``next(g)`` returns ``RETRY_DELAYS[0]``; subsequent calls
|
|
36
|
+
walk the ladder and, once exhausted, keep yielding the tail delay
|
|
37
|
+
forever. Callers don't have to guard against ``StopIteration`` —
|
|
38
|
+
``next(g)`` always returns a valid sleep time — and don't have to
|
|
39
|
+
do any index arithmetic to derive it. A caller that wants a
|
|
40
|
+
finite retry budget bounds it externally (e.g. an attempt counter)."""
|
|
41
|
+
|
|
42
|
+
def gen() -> Iterator[float]:
|
|
43
|
+
# `yield 0` gives a caller who *doesn't* prime a clean way to
|
|
44
|
+
# sleep uniformly before every attempt (including the first,
|
|
45
|
+
# which is a no-op). We prime here so callers who don't want
|
|
46
|
+
# that get the real first delay straight away.
|
|
47
|
+
yield 0
|
|
48
|
+
yield from RETRY_DELAYS
|
|
49
|
+
while True:
|
|
50
|
+
yield RETRY_DELAYS[-1]
|
|
51
|
+
|
|
52
|
+
g = gen()
|
|
53
|
+
next(g)
|
|
54
|
+
return g
|
|
55
|
+
|
|
27
56
|
|
|
28
57
|
# Socket-level / connection-level errors that warrant a retry. DNS hiccups
|
|
29
58
|
# (gaierror), refused/reset connections, and broken HTTP framing are all
|
|
@@ -38,9 +67,17 @@ RETRYABLE_NETWORK_ERRORS: tuple[type[BaseException], ...] = (
|
|
|
38
67
|
|
|
39
68
|
def cli_version() -> str:
|
|
40
69
|
try:
|
|
41
|
-
|
|
70
|
+
dist = distribution("contree-cli")
|
|
42
71
|
except PackageNotFoundError:
|
|
43
72
|
return "editable"
|
|
73
|
+
raw = dist.read_text("direct_url.json")
|
|
74
|
+
if raw:
|
|
75
|
+
try:
|
|
76
|
+
if json.loads(raw).get("dir_info", {}).get("editable"):
|
|
77
|
+
return "editable"
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass
|
|
80
|
+
return dist.version
|
|
44
81
|
|
|
45
82
|
|
|
46
83
|
CLI_USER_AGENT = (
|
|
@@ -138,6 +175,88 @@ class BodyFormatter:
|
|
|
138
175
|
return f"<binary {len(data)}B>"
|
|
139
176
|
|
|
140
177
|
|
|
178
|
+
class GzipResponse:
|
|
179
|
+
"""Wrap an HTTPResponse, inflating `Content-Encoding: gzip` via
|
|
180
|
+
`zlib.decompressobj(wbits=31)` so Z_SYNC_FLUSH'd SSE frames decode
|
|
181
|
+
incrementally without buffering whole responses."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, resp: http.client.HTTPResponse) -> None:
|
|
184
|
+
self.resp = resp
|
|
185
|
+
self.decomp = zlib.decompressobj(wbits=31)
|
|
186
|
+
self.buf = bytearray()
|
|
187
|
+
self.eof = False
|
|
188
|
+
|
|
189
|
+
def pump(self, want: int = -1) -> None:
|
|
190
|
+
while not self.eof and (want < 0 or len(self.buf) < want):
|
|
191
|
+
# read1() returns whatever's in the socket buffer after one
|
|
192
|
+
# underlying read; plain read(n) blocks for n bytes, which
|
|
193
|
+
# stalls SSE streaming because tiny frames never fill it.
|
|
194
|
+
chunk = self.resp.read1(8192)
|
|
195
|
+
if not chunk:
|
|
196
|
+
tail = self.decomp.flush()
|
|
197
|
+
if tail:
|
|
198
|
+
self.buf.extend(tail)
|
|
199
|
+
self.eof = True
|
|
200
|
+
return
|
|
201
|
+
decoded = self.decomp.decompress(chunk)
|
|
202
|
+
if decoded:
|
|
203
|
+
self.buf.extend(decoded)
|
|
204
|
+
|
|
205
|
+
def read(self, amt: int | None = None) -> bytes:
|
|
206
|
+
if amt is None or amt < 0:
|
|
207
|
+
self.pump()
|
|
208
|
+
out = bytes(self.buf)
|
|
209
|
+
self.buf.clear()
|
|
210
|
+
return out
|
|
211
|
+
self.pump(amt)
|
|
212
|
+
out = bytes(self.buf[:amt])
|
|
213
|
+
del self.buf[:amt]
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
def readline(self, limit: int = -1) -> bytes:
|
|
217
|
+
while True:
|
|
218
|
+
nl = self.buf.find(b"\n")
|
|
219
|
+
if nl >= 0:
|
|
220
|
+
end = nl + 1
|
|
221
|
+
if 0 <= limit < end:
|
|
222
|
+
end = limit
|
|
223
|
+
out = bytes(self.buf[:end])
|
|
224
|
+
del self.buf[:end]
|
|
225
|
+
return out
|
|
226
|
+
if self.eof:
|
|
227
|
+
out = bytes(self.buf)
|
|
228
|
+
self.buf.clear()
|
|
229
|
+
return out
|
|
230
|
+
self.pump(want=len(self.buf) + 1)
|
|
231
|
+
|
|
232
|
+
def close(self) -> None:
|
|
233
|
+
with contextlib.suppress(Exception):
|
|
234
|
+
self.resp.close()
|
|
235
|
+
|
|
236
|
+
def getheader(self, name: str, default: str | None = None) -> str | None:
|
|
237
|
+
# Hide Content-Encoding so downstream readers don't try a
|
|
238
|
+
# second decompression; Content-Length is bogus post-inflate.
|
|
239
|
+
lname = name.lower()
|
|
240
|
+
if lname in ("content-encoding", "content-length"):
|
|
241
|
+
return default
|
|
242
|
+
return self.resp.getheader(name, default)
|
|
243
|
+
|
|
244
|
+
def getheaders(self) -> list[tuple[str, str]]:
|
|
245
|
+
return [
|
|
246
|
+
(k, v)
|
|
247
|
+
for k, v in self.resp.getheaders()
|
|
248
|
+
if k.lower() not in ("content-encoding", "content-length")
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def status(self) -> int:
|
|
253
|
+
return self.resp.status
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def reason(self) -> str:
|
|
257
|
+
return self.resp.reason
|
|
258
|
+
|
|
259
|
+
|
|
141
260
|
class BufferedResponse:
|
|
142
261
|
"""Replay an HTTPResponse from buffered bytes (for debug body logging)."""
|
|
143
262
|
|
|
@@ -221,6 +340,7 @@ class ContreeClient(ABC):
|
|
|
221
340
|
merged: dict[str, str] = {
|
|
222
341
|
**self._build_headers(),
|
|
223
342
|
"User-Agent": CLI_USER_AGENT,
|
|
343
|
+
"Accept-Encoding": "gzip",
|
|
224
344
|
}
|
|
225
345
|
if body is not None:
|
|
226
346
|
merged.setdefault("Content-Type", "application/json")
|
|
@@ -228,9 +348,6 @@ class ContreeClient(ABC):
|
|
|
228
348
|
merged.update(headers)
|
|
229
349
|
|
|
230
350
|
full_path = self._prefix + path
|
|
231
|
-
last_error: ApiError | None = None
|
|
232
|
-
last_network_error: BaseException | None = None
|
|
233
|
-
attempts = len(RETRY_DELAYS) + 1
|
|
234
351
|
|
|
235
352
|
log.debug(
|
|
236
353
|
"%s %s headers=%s body=%s",
|
|
@@ -240,25 +357,21 @@ class ContreeClient(ABC):
|
|
|
240
357
|
BodyFormatter(body, content_type=merged.get("Content-Type", "")),
|
|
241
358
|
)
|
|
242
359
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
)
|
|
259
|
-
time.sleep(delay)
|
|
260
|
-
|
|
261
|
-
if attempt > 0 and hasattr(body, "seek"):
|
|
360
|
+
# Backoff delays come from a primed `retry_generator()` — each
|
|
361
|
+
# failure just calls `next(delays)` and gets the next value.
|
|
362
|
+
# The generator yields the tail delay forever, and there is no
|
|
363
|
+
# outer retry budget: transient errors are retried until either
|
|
364
|
+
# the server returns a non-retryable response or the user hits
|
|
365
|
+
# Ctrl+C. Non-retryable 4xx responses raise immediately.
|
|
366
|
+
delays = retry_generator()
|
|
367
|
+
|
|
368
|
+
def sleep_for_retry(reason_fmt: str, *reason_args: object) -> None:
|
|
369
|
+
"""Pull the next backoff delay, log the reason, sleep, and
|
|
370
|
+
rewind the request body if it's seekable."""
|
|
371
|
+
delay = next(delays)
|
|
372
|
+
log.warning(reason_fmt + " retrying in %ss…", *reason_args, delay)
|
|
373
|
+
time.sleep(delay)
|
|
374
|
+
if body is not None and hasattr(body, "seek"):
|
|
262
375
|
stream = cast(IO[bytes], body)
|
|
263
376
|
if not stream.seekable():
|
|
264
377
|
raise ApiError(
|
|
@@ -267,6 +380,8 @@ class ContreeClient(ABC):
|
|
|
267
380
|
"Cannot retry: streaming body is not seekable",
|
|
268
381
|
)
|
|
269
382
|
stream.seek(0)
|
|
383
|
+
|
|
384
|
+
while True:
|
|
270
385
|
try:
|
|
271
386
|
conn = self._connect()
|
|
272
387
|
conn.request(method, full_path, body, merged)
|
|
@@ -278,13 +393,11 @@ class ContreeClient(ABC):
|
|
|
278
393
|
# would just spin through the back-off ladder for nothing.
|
|
279
394
|
raise
|
|
280
395
|
except RETRYABLE_NETWORK_ERRORS as exc:
|
|
281
|
-
|
|
282
|
-
last_error = None
|
|
396
|
+
sleep_for_retry("Network error (%s),", type(exc).__name__)
|
|
283
397
|
continue
|
|
284
398
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
last_network_error = None
|
|
399
|
+
if (resp.getheader("Content-Encoding", "") or "").lower() == "gzip":
|
|
400
|
+
resp = cast(http.client.HTTPResponse, GzipResponse(resp))
|
|
288
401
|
|
|
289
402
|
if 200 <= resp.status < 300:
|
|
290
403
|
log.debug(
|
|
@@ -321,17 +434,12 @@ class ContreeClient(ABC):
|
|
|
321
434
|
)
|
|
322
435
|
error = ApiError(resp.status, resp.reason, resp_body)
|
|
323
436
|
|
|
324
|
-
if 500 <= resp.status < 600:
|
|
325
|
-
|
|
437
|
+
if resp.status in (410, 425) or 500 <= resp.status < 600:
|
|
438
|
+
sleep_for_retry("Server error %d,", resp.status)
|
|
326
439
|
continue
|
|
327
440
|
|
|
328
441
|
raise error
|
|
329
442
|
|
|
330
|
-
if last_network_error is not None:
|
|
331
|
-
raise last_network_error
|
|
332
|
-
assert last_error is not None
|
|
333
|
-
raise last_error
|
|
334
|
-
|
|
335
443
|
def log_and_buffer(
|
|
336
444
|
self,
|
|
337
445
|
method: str,
|
|
@@ -340,8 +448,10 @@ class ContreeClient(ABC):
|
|
|
340
448
|
) -> http.client.HTTPResponse:
|
|
341
449
|
"""Read & log a textual response body; pass binary streams through."""
|
|
342
450
|
content_type = resp.getheader("Content-Type", "") or ""
|
|
451
|
+
# event-stream is line-streamed and unbounded — buffering it would block.
|
|
452
|
+
is_event_stream = "event-stream" in content_type
|
|
343
453
|
textual = not content_type or "json" in content_type or "text" in content_type
|
|
344
|
-
if not textual:
|
|
454
|
+
if is_event_stream or not textual:
|
|
345
455
|
log.debug(
|
|
346
456
|
"%s %s response body: <stream Content-Type=%r>",
|
|
347
457
|
method,
|
|
@@ -535,6 +645,73 @@ def stream_response(
|
|
|
535
645
|
yield chunk
|
|
536
646
|
|
|
537
647
|
|
|
648
|
+
def iter_sse_events(resp: http.client.HTTPResponse) -> Iterator[dict[str, object]]:
|
|
649
|
+
"""Yield one dict per SSE frame.
|
|
650
|
+
|
|
651
|
+
Normal frames carry a JSON object in ``data:`` — that object is
|
|
652
|
+
yielded as-is. Server-pushed error frames use ``event: sse_error``
|
|
653
|
+
with a plain-text ``data:`` body (the exception message) — those
|
|
654
|
+
surface as ``{"type": "sse_error", "message": <text>, "id": ...}``
|
|
655
|
+
so callers can log + decide whether to reconnect with
|
|
656
|
+
``Last-Event-Id`` set to the last good id.
|
|
657
|
+
"""
|
|
658
|
+
data_lines: list[str] = []
|
|
659
|
+
event_name: str | None = None
|
|
660
|
+
event_id: str | None = None
|
|
661
|
+
|
|
662
|
+
def emit() -> Iterator[dict[str, object]]:
|
|
663
|
+
nonlocal event_name, event_id
|
|
664
|
+
if not data_lines:
|
|
665
|
+
return
|
|
666
|
+
body = "\n".join(data_lines)
|
|
667
|
+
if event_name == "sse_error":
|
|
668
|
+
payload: dict[str, object] = {"type": "sse_error", "message": body}
|
|
669
|
+
if event_id is not None:
|
|
670
|
+
payload["id"] = event_id
|
|
671
|
+
yield payload
|
|
672
|
+
else:
|
|
673
|
+
with suppress(json.JSONDecodeError):
|
|
674
|
+
decoded = json.loads(body)
|
|
675
|
+
if isinstance(decoded, dict):
|
|
676
|
+
yield decoded
|
|
677
|
+
data_lines.clear()
|
|
678
|
+
event_name = None
|
|
679
|
+
event_id = None
|
|
680
|
+
|
|
681
|
+
while True:
|
|
682
|
+
line_bytes = resp.readline()
|
|
683
|
+
if not line_bytes:
|
|
684
|
+
yield from emit()
|
|
685
|
+
return
|
|
686
|
+
line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
687
|
+
match line:
|
|
688
|
+
case "":
|
|
689
|
+
yield from emit()
|
|
690
|
+
case _ if line.startswith(":"):
|
|
691
|
+
pass # SSE comment / keepalive
|
|
692
|
+
case _ if line.startswith("data:"):
|
|
693
|
+
data_lines.append(line[5:].lstrip(" "))
|
|
694
|
+
case _ if line.startswith("event:"):
|
|
695
|
+
event_name = line[6:].lstrip(" ").strip() or None
|
|
696
|
+
case _ if line.startswith("id:"):
|
|
697
|
+
event_id = line[3:].lstrip(" ").strip() or None
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def decode_event_chunk(data: object) -> bytes:
|
|
701
|
+
"""Decode `{value, encoding}` event payload to raw bytes (ascii or base64)."""
|
|
702
|
+
if not isinstance(data, dict):
|
|
703
|
+
return b""
|
|
704
|
+
value = data.get("value", "")
|
|
705
|
+
if not isinstance(value, str) or not value:
|
|
706
|
+
return b""
|
|
707
|
+
encoding = data.get("encoding", "ascii")
|
|
708
|
+
if encoding == "base64":
|
|
709
|
+
with suppress(binascii.Error, ValueError):
|
|
710
|
+
return base64.b64decode(value)
|
|
711
|
+
return b""
|
|
712
|
+
return value.encode("utf-8", errors="replace")
|
|
713
|
+
|
|
714
|
+
|
|
538
715
|
def decode_stream(stream: dict[str, object] | None) -> str:
|
|
539
716
|
"""Decode an API StreamRepr object to a string."""
|
|
540
717
|
if not stream:
|
|
@@ -5,19 +5,22 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
7
|
import shlex
|
|
8
|
-
import time
|
|
9
8
|
from dataclasses import dataclass, field
|
|
10
|
-
from typing import ClassVar
|
|
9
|
+
from typing import Any, ClassVar
|
|
11
10
|
|
|
11
|
+
from contree_cli.cli.run import (
|
|
12
|
+
TERMINAL_OP_STATUSES,
|
|
13
|
+
_build_op_from_summary,
|
|
14
|
+
_stream_events_until_close,
|
|
15
|
+
)
|
|
12
16
|
from contree_cli.client import decode_stream
|
|
17
|
+
from contree_cli.output import DefaultFormatter
|
|
13
18
|
|
|
14
19
|
from .context import BuildContext
|
|
15
20
|
from .keyword import DockerKeyword, parse_command_form
|
|
16
21
|
|
|
17
22
|
logger = logging.getLogger(__name__)
|
|
18
23
|
|
|
19
|
-
TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
|
|
20
|
-
|
|
21
24
|
|
|
22
25
|
@dataclass(frozen=True, repr=False)
|
|
23
26
|
class RunKeyword(DockerKeyword):
|
|
@@ -91,33 +94,42 @@ class RunKeyword(DockerKeyword):
|
|
|
91
94
|
payload["files"] = ctx.pending_files_payload()
|
|
92
95
|
|
|
93
96
|
resp = ctx.client.post_json("/v1/instances", payload)
|
|
94
|
-
|
|
95
|
-
op_uuid: str =
|
|
97
|
+
spawn_op = json.loads(resp.read())
|
|
98
|
+
op_uuid: str = spawn_op["uuid"]
|
|
96
99
|
logger.info(
|
|
97
100
|
"RUN spawned op=%s: %s", op_uuid, display_title(parts, self.shell_form)
|
|
98
101
|
)
|
|
99
102
|
|
|
100
|
-
op =
|
|
103
|
+
op = stream_and_resolve(ctx, op_uuid)
|
|
101
104
|
check_success(op, parts, self.shell_form)
|
|
102
105
|
result = op.get("result") or {}
|
|
103
106
|
assert isinstance(result, dict)
|
|
104
107
|
new_image = result.get("image")
|
|
105
108
|
if not new_image:
|
|
106
109
|
raise RuntimeError("RUN succeeded but no image was produced")
|
|
107
|
-
log_streams(op)
|
|
108
110
|
return str(new_image), op_uuid
|
|
109
111
|
|
|
110
112
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
113
|
+
def stream_and_resolve(ctx: BuildContext, op_uuid: str) -> dict[str, Any]:
|
|
114
|
+
"""Stream RUN output live (docker-style), then materialise the
|
|
115
|
+
full op dict.
|
|
116
|
+
|
|
117
|
+
Preference order for the op payload matches ``cmd_run``:
|
|
118
|
+
``completion`` event → SSE `fallback_op` from a terminal GET →
|
|
119
|
+
safety-net GET. When we fell back to the plain endpoint (no
|
|
120
|
+
live stream), replay the captured stdout/stderr from the op's
|
|
121
|
+
metadata so build users still see what the RUN produced."""
|
|
122
|
+
summary = _stream_events_until_close(ctx.client, op_uuid, DefaultFormatter())
|
|
123
|
+
if summary.completion is not None:
|
|
124
|
+
return _build_op_from_summary(op_uuid, summary)
|
|
125
|
+
if summary.fallback_op is not None:
|
|
126
|
+
log_streams(summary.fallback_op)
|
|
127
|
+
return summary.fallback_op
|
|
128
|
+
resp = ctx.client.get(f"/v1/operations/{op_uuid}")
|
|
129
|
+
op: dict[str, Any] = json.loads(resp.read())
|
|
130
|
+
if op.get("status") in TERMINAL_OP_STATUSES:
|
|
131
|
+
log_streams(op)
|
|
132
|
+
return op
|
|
121
133
|
|
|
122
134
|
|
|
123
135
|
def check_success(
|
|
@@ -23,7 +23,6 @@ import urllib.error
|
|
|
23
23
|
import urllib.parse
|
|
24
24
|
import urllib.request
|
|
25
25
|
from dataclasses import dataclass
|
|
26
|
-
from typing import IO, cast
|
|
27
26
|
|
|
28
27
|
from contree_cli.client import ApiError, ContreeClient
|
|
29
28
|
from contree_cli.session import SessionStore
|
|
@@ -79,27 +78,25 @@ def fetch_and_upload(
|
|
|
79
78
|
|
|
80
79
|
assert source is not None
|
|
81
80
|
try:
|
|
82
|
-
|
|
83
|
-
upload_headers: dict[str, str] = {"Content-Type": "application/octet-stream"}
|
|
84
|
-
content_length = parse_content_length(response_headers)
|
|
85
|
-
if content_length > 0:
|
|
86
|
-
upload_headers["Content-Length"] = str(content_length)
|
|
87
|
-
|
|
88
|
-
resp = client.request(
|
|
89
|
-
"POST",
|
|
90
|
-
"/v1/files",
|
|
91
|
-
body=cast(IO[bytes], reader),
|
|
92
|
-
headers=upload_headers,
|
|
93
|
-
)
|
|
94
|
-
data = json.loads(resp.read())
|
|
81
|
+
body_bytes = source.read() # type: ignore[attr-defined]
|
|
95
82
|
finally:
|
|
96
83
|
close = getattr(source, "close", None)
|
|
97
84
|
if callable(close):
|
|
98
85
|
close()
|
|
86
|
+
assert isinstance(body_bytes, (bytes, bytearray))
|
|
87
|
+
body_bytes = bytes(body_bytes)
|
|
88
|
+
|
|
89
|
+
resp = client.request(
|
|
90
|
+
"POST",
|
|
91
|
+
"/v1/files",
|
|
92
|
+
body=body_bytes,
|
|
93
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
94
|
+
)
|
|
95
|
+
data = json.loads(resp.read())
|
|
99
96
|
|
|
100
97
|
file_uuid = str(data["uuid"])
|
|
101
|
-
sha =
|
|
102
|
-
size =
|
|
98
|
+
sha = hashlib.sha256(body_bytes).hexdigest()
|
|
99
|
+
size = len(body_bytes)
|
|
103
100
|
|
|
104
101
|
write_metadata(
|
|
105
102
|
store,
|
|
@@ -195,38 +192,6 @@ def conditional_headers(meta: dict[str, object]) -> dict[str, str]:
|
|
|
195
192
|
return headers
|
|
196
193
|
|
|
197
194
|
|
|
198
|
-
def parse_content_length(headers: dict[str, str]) -> int:
|
|
199
|
-
raw = headers.get("content-length")
|
|
200
|
-
if not raw:
|
|
201
|
-
return -1
|
|
202
|
-
try:
|
|
203
|
-
return int(raw)
|
|
204
|
-
except ValueError:
|
|
205
|
-
return -1
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
class HashingReader:
|
|
209
|
-
"""Read-only adapter that hashes bytes as they flow through.
|
|
210
|
-
|
|
211
|
-
Intentionally has no ``seek`` attribute so ``ContreeClient.request``
|
|
212
|
-
does not attempt to rewind the upstream HTTP body on retry.
|
|
213
|
-
"""
|
|
214
|
-
|
|
215
|
-
__slots__ = ("bytes_read", "hasher", "source")
|
|
216
|
-
|
|
217
|
-
def __init__(self, source: object) -> None:
|
|
218
|
-
self.source = source
|
|
219
|
-
self.hasher = hashlib.sha256()
|
|
220
|
-
self.bytes_read = 0
|
|
221
|
-
|
|
222
|
-
def read(self, amt: int | None = None) -> bytes:
|
|
223
|
-
chunk = self.source.read() if amt is None else self.source.read(amt) # type: ignore[attr-defined]
|
|
224
|
-
if chunk:
|
|
225
|
-
self.hasher.update(chunk)
|
|
226
|
-
self.bytes_read += len(chunk)
|
|
227
|
-
return chunk # type: ignore[no-any-return]
|
|
228
|
-
|
|
229
|
-
|
|
230
195
|
def http_head(url: str, *, timeout: int = DOWNLOAD_TIMEOUT_DEFAULT) -> dict[str, str]:
|
|
231
196
|
"""Best-effort ``HEAD`` request. Returns empty headers on any failure.
|
|
232
197
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "contree-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "CLI client for the ConTree sandbox platform"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -58,6 +58,7 @@ docs = [
|
|
|
58
58
|
"furo",
|
|
59
59
|
"sphinx-design",
|
|
60
60
|
"myst-parser",
|
|
61
|
+
"sphinx-mintlify-output",
|
|
61
62
|
]
|
|
62
63
|
|
|
63
64
|
[tool.pytest.ini_options]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|