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.
Files changed (67) hide show
  1. {contree_cli-0.7.1 → contree_cli-0.8.0}/PKG-INFO +1 -1
  2. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/operation.py +14 -1
  3. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/run.py +264 -15
  4. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/show.py +1 -1
  5. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/client.py +215 -38
  6. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_run.py +30 -18
  7. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/url_fetch.py +13 -48
  8. {contree_cli-0.7.1 → contree_cli-0.8.0}/pyproject.toml +2 -1
  9. {contree_cli-0.7.1 → contree_cli-0.8.0}/LICENSE +0 -0
  10. {contree_cli-0.7.1 → contree_cli-0.8.0}/README.md +0 -0
  11. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/__init__.py +0 -0
  12. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/__main__.py +0 -0
  13. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/agent.md +0 -0
  14. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/arguments.py +0 -0
  15. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/__init__.py +0 -0
  16. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/agent.py +0 -0
  17. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/auth.py +0 -0
  18. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/build.py +0 -0
  19. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/cat.py +0 -0
  20. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/cd.py +0 -0
  21. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/cp.py +0 -0
  22. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/env.py +0 -0
  23. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/file.py +0 -0
  24. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/images.py +0 -0
  25. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/ls.py +0 -0
  26. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/session.py +0 -0
  27. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/skill.py +0 -0
  28. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/tag.py +0 -0
  29. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/cli/use.py +0 -0
  30. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/config.py +0 -0
  31. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/__init__.py +0 -0
  32. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/context.py +0 -0
  33. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/dockerignore.py +0 -0
  34. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/keyword.py +0 -0
  35. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_add.py +0 -0
  36. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_arg.py +0 -0
  37. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_copy.py +0 -0
  38. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_env.py +0 -0
  39. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_from.py +0 -0
  40. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_skipped.py +0 -0
  41. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_user.py +0 -0
  42. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/kw_workdir.py +0 -0
  43. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/local_context.py +0 -0
  44. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/docker/parser.py +0 -0
  45. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/log.py +0 -0
  46. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/man.py +0 -0
  47. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/manual.md +0 -0
  48. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/mapped_file.py +0 -0
  49. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/migrations.py +0 -0
  50. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/output.py +0 -0
  51. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/py.typed +0 -0
  52. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/refs.py +0 -0
  53. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/session.py +0 -0
  54. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/__init__.py +0 -0
  55. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/argmap.py +0 -0
  56. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/argspec.py +0 -0
  57. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/cache.py +0 -0
  58. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/completer.py +0 -0
  59. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/history.py +0 -0
  60. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/parser.py +0 -0
  61. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/repl.py +0 -0
  62. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/sources.py +0 -0
  63. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/shell/trie.py +0 -0
  64. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/skill.py +0 -0
  65. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/skill_body.md +0 -0
  66. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/types.py +0 -0
  67. {contree_cli-0.7.1 → contree_cli-0.8.0}/contree_cli/update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: contree-cli
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: CLI client for the ConTree sandbox platform
5
5
  Keywords: sandbox,cli,vm,ai-agent,contree
6
6
  Author: Dmitry Orlov
@@ -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 ApiError, ContreeClient, decode_stream, resolve_image
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
- # For DefaultFormatter, just only print stdout/stderr
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. Poll until terminal status
692
- sleep_time = 0.5
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
- time.sleep(sleep_time)
695
- sleep_time += sleep_time
696
- while True:
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
- if op["status"] in TERMINAL_STATUSES:
700
- break
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 is not None:
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 importlib.metadata import PackageNotFoundError, version
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, 4, 5, 10, 10, 10)
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
- return version("contree-cli")
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
- for attempt in range(attempts):
244
- if last_error is not None or last_network_error is not None:
245
- delay = RETRY_DELAYS[attempt - 1]
246
- if last_network_error is not None:
247
- log.warning(
248
- "Network error (%s), retrying in %ds…",
249
- type(last_network_error).__name__,
250
- delay,
251
- )
252
- else:
253
- assert last_error is not None
254
- log.warning(
255
- "Server error %d, retrying in %ds…",
256
- last_error.status,
257
- delay,
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
- last_network_error = exc
282
- last_error = None
396
+ sleep_for_retry("Network error (%s),", type(exc).__name__)
283
397
  continue
284
398
 
285
- # Successful round-trip clears the network-error trail so the
286
- # final raise below doesn't pick up stale failure context.
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
- last_error = error
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
- op = json.loads(resp.read())
95
- op_uuid: str = op["uuid"]
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 = poll(ctx, op_uuid)
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 poll(ctx: BuildContext, op_uuid: str) -> dict[str, object]:
112
- delay = 0.5
113
- while True:
114
- time.sleep(delay)
115
- resp = ctx.client.get(f"/v1/operations/{op_uuid}")
116
- op = json.loads(resp.read())
117
- if op["status"] in TERMINAL_STATUSES:
118
- return op # type: ignore[no-any-return]
119
- if delay < 5:
120
- delay += delay
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
- reader = HashingReader(source)
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 = reader.hasher.hexdigest()
102
- size = reader.bytes_read
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.7.1"
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