diff --git a/chi/container.py b/chi/container.py index bd854af..636b346 100644 --- a/chi/container.py +++ b/chi/container.py @@ -113,11 +113,14 @@ def from_zun_container(cls, zun_container): return container @property - def status(self): + def zun_container(self): if self.id: - container = zun().containers.get(self.id) - self._status = container.status - return self._status + self._zun_container = zun().containers.get(self.id) + return self._zun_container + + @property + def status(self): + return getattr(self.zun_container, "status") def submit( self, @@ -211,13 +214,47 @@ def wait( if show == "widget" and context._is_ipynb(): pb.display() + state = {"status": None, "since": time.perf_counter(), "conditions": {}} + + def _zun_attr(obj, name): + val = getattr(obj, name, None) + return val if val and val != "None" else None + def _callback(): - # self.status is a property that refreshes itself - # NOTE: zun statuses are title case - if self.status.upper() == status.upper() or self.status == "Error": - print(f"Container has moved to status {self.status}") - return True - return False + zun_container = self.zun_container + now = time.perf_counter() + elapsed = int(now - state["since"]) + + current_status = getattr(zun_container, "status", None) + detail = _zun_attr(zun_container, "status_detail") + reason = _zun_attr(zun_container, "status_reason") + + if current_status != state["status"]: + if state["status"]: + pb.log(f"[{elapsed}s] {state['status']} -> {current_status}") + state["status"] = current_status + state["since"] = now + state["conditions"] = {} + + if detail: + entry = state["conditions"].setdefault(detail, {"count": 0}) + entry["count"] += 1 + entry["reason"] = reason + + parts = [f"{current_status} ({elapsed}s)"] + for name, entry in state["conditions"].items(): + count = entry["count"] + + if count > 1: + line = f"{name} (x{count})" + else: + line = name + if entry.get("reason"): + line = f"{line} {entry['reason']}" + parts.append(line) + pb.update_status("\n".join(parts)) + + return current_status == "Error" or current_status.upper() == status.upper() res = pb.wait(_callback, 2 * 60, timeout) if not res: diff --git a/chi/util.py b/chi/util.py index 6ee45f9..0aa4741 100644 --- a/chi/util.py +++ b/chi/util.py @@ -1,4 +1,5 @@ import base64 +import logging import os import time from datetime import datetime, timedelta @@ -10,6 +11,8 @@ from chi.exception import ResourceError +LOG = logging.getLogger(__name__) + def random_base32(n_bytes): rand_bytes = os.urandom(n_bytes) @@ -60,9 +63,45 @@ def __init__(self): orientation="horizontal", ) self.label = widgets.Label() + self.status_output = widgets.HTML() + self._log_lines = [] + self._current_status_text = "" + self._is_displayed = False def display(self): - display(widgets.HBox([self.label, self.progress])) + display( + widgets.VBox( + [ + widgets.HBox([self.label, self.progress]), + self.status_output, + ] + ) + ) + self._is_displayed = True + + def log(self, msg): + self._log_lines.append(msg) + if self._is_displayed: + self._render() + else: + LOG.info(msg) + + def update_status(self, msg): + self._current_status_text = msg + if self._is_displayed: + self._render() + + def _render(self): + from html import escape + + lines = list(self._log_lines) + if self._current_status_text: + lines.append(self._current_status_text) + inner = "\n".join(escape(line) for line in lines) + self.status_output.value = ( + '
'
+ f"{inner}"
+ )
def wait(self, callback, expected_timeout, timeout, interval=5):
"""Wait and update the progress bar.
diff --git a/tests/test_container.py b/tests/test_container.py
index 60a7f9c..adf1fcd 100644
--- a/tests/test_container.py
+++ b/tests/test_container.py
@@ -192,3 +192,84 @@ def _get_side_effect(ref):
# disable optional behavor from wait_for_active and show
chi_container.submit(wait_for_active=False, show=None)
assert chi_container.id == "new-uuid"
+
+
+# --- Container.wait() status display tests ---
+
+
+def _make_zun_state(mocker, status, detail=None, reason=None):
+ return mocker.Mock(status=status, status_detail=detail, status_reason=reason)
+
+
+def _setup_wait(mocker, states):
+ """Common setup for wait tests: mock zun, ipynb check, and progress bar."""
+ container = Container(name="t", image_ref="img")
+ container.id = "fake"
+
+ zun_mock = mocker.patch("chi.container.zun")()
+ zun_mock.containers.get.side_effect = states
+ mocker.patch("chi.container.context._is_ipynb", return_value=False)
+
+ pb_cls = mocker.patch("chi.container.util.TimerProgressBar")
+ pb = pb_cls.return_value
+
+ def fake_wait(cb, expected, timeout, interval=5):
+ for _ in states:
+ if cb():
+ return True
+ return False
+
+ pb.wait.side_effect = fake_wait
+ return container, pb
+
+
+def test_wait_logs_status_transitions(mocker):
+ """Status changes produce log lines; same status does not."""
+ states = [
+ _make_zun_state(mocker, "Creating"),
+ _make_zun_state(mocker, "Creating"),
+ _make_zun_state(mocker, "Running"),
+ ]
+ container, pb = _setup_wait(mocker, states)
+
+ container.wait(status="Running")
+
+ log_calls = [c.args[0] for c in pb.log.call_args_list]
+ assert any("Running" in log for log in log_calls)
+ assert len(log_calls) == 1 # one transition
+
+
+def test_wait_accumulates_events_without_duplicates(mocker):
+ """Different details accumulate; same detail is not repeated."""
+ states = [
+ _make_zun_state(mocker, "Creating", detail="Pulling image"),
+ _make_zun_state(mocker, "Creating", detail="Pulling image"),
+ _make_zun_state(mocker, "Creating", detail="Configuring net"),
+ _make_zun_state(mocker, "Running"),
+ ]
+ container, pb = _setup_wait(mocker, states)
+
+ captured_updates = []
+ pb.update_status.side_effect = lambda msg: captured_updates.append(msg)
+
+ container.wait(status="Running")
+
+ creating_updates = [u for u in captured_updates if "Creating" in u]
+ last_creating = creating_updates[-1]
+ assert "Pulling image" in last_creating
+ assert "Configuring net" in last_creating
+ assert last_creating.count("Pulling image") == 1
+
+
+def test_wait_stops_on_error(mocker):
+ """Error status stops the wait loop and is logged."""
+ states = [
+ _make_zun_state(mocker, "Creating"),
+ _make_zun_state(mocker, "Error", detail="OOM", reason="Out of memory"),
+ ]
+ container, pb = _setup_wait(mocker, states)
+
+ container.wait(status="Running")
+
+ log_calls = [c.args[0] for c in pb.log.call_args_list]
+ assert any("Error" in log for log in log_calls)