Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit 537a587

Browse files
committed
Lease progress details while users wait
1 parent 0f1555f commit 537a587

File tree

2 files changed

+349
-31
lines changed

2 files changed

+349
-31
lines changed

packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 119 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import logging
2+
import os
3+
import sys
24
from collections.abc import AsyncGenerator, Generator
35
from contextlib import (
46
ExitStack,
@@ -20,6 +22,7 @@
2022
from anyio.from_thread import BlockingPortal
2123
from grpc.aio import AioRpcError, Channel
2224
from jumpstarter_protocol import jumpstarter_pb2, jumpstarter_pb2_grpc
25+
from rich.console import Console
2326

2427
from .exceptions import LeaseError
2528
from jumpstarter.client import client_from_path
@@ -112,44 +115,68 @@ async def request_async(self):
112115

113116
return await self._acquire()
114117

118+
def _update_spinner_status(self, spinner, result):
119+
"""Update spinner with appropriate status message based on lease conditions."""
120+
if condition_true(result.conditions, "Pending"):
121+
pending_message = condition_message(result.conditions, "Pending")
122+
if pending_message:
123+
spinner.update_status(f"Waiting for lease: {pending_message}")
124+
else:
125+
spinner.update_status("Waiting for lease to be ready...")
126+
else:
127+
spinner.update_status("Waiting for server to provide status updates...")
128+
115129
async def _acquire(self):
116130
"""Acquire a lease.
117131
118132
Makes sure the lease is ready, and returns the lease object.
119133
"""
120134
try:
121135
with fail_after(self.acquisition_timeout):
122-
while True:
123-
logger.debug("Polling Lease %s", self.name)
124-
result = await self.get()
125-
# lease ready
126-
if condition_true(result.conditions, "Ready"):
127-
logger.debug("Lease %s acquired", self.name)
128-
self.exporter_name = result.exporter
129-
return self
130-
# lease unsatisfiable
131-
if condition_true(result.conditions, "Unsatisfiable"):
132-
message = condition_message(result.conditions, "Unsatisfiable")
133-
logger.debug("Lease %s cannot be satisfied: %s", self.name, message)
134-
raise LeaseError(f"the lease cannot be satisfied: {message}")
135-
136-
# lease invalid
137-
if condition_true(result.conditions, "Invalid"):
138-
message = condition_message(result.conditions, "Invalid")
139-
logger.debug("Lease %s is invalid: %s", self.name, message)
140-
raise LeaseError(f"the lease is invalid: {message}")
141-
142-
# lease not pending
143-
if condition_false(result.conditions, "Pending"):
144-
raise LeaseError(
145-
f"Lease {self.name} is not in pending, but it isn't in Ready or Unsatisfiable state either"
146-
)
147-
148-
# lease released
149-
if condition_present_and_equal(result.conditions, "Ready", "False", "Released"):
150-
raise LeaseError(f"lease {self.name} released")
151-
152-
await sleep(5)
136+
with LeaseAcquisitionSpinner(self.name) as spinner:
137+
while True:
138+
logger.debug("Polling Lease %s", self.name)
139+
result = await self.get()
140+
141+
# lease ready
142+
if condition_true(result.conditions, "Ready"):
143+
logger.debug("Lease %s acquired", self.name)
144+
spinner.update_status(f"✅ Lease {self.name} acquired successfully!")
145+
self.exporter_name = result.exporter
146+
break
147+
148+
# lease unsatisfiable
149+
if condition_true(result.conditions, "Unsatisfiable"):
150+
message = condition_message(result.conditions, "Unsatisfiable")
151+
logger.debug("Lease %s cannot be satisfied: %s", self.name, message)
152+
raise LeaseError(f"the lease cannot be satisfied: {message}")
153+
154+
# lease invalid
155+
if condition_true(result.conditions, "Invalid"):
156+
message = condition_message(result.conditions, "Invalid")
157+
logger.debug("Lease %s is invalid: %s", self.name, message)
158+
raise LeaseError(f"the lease is invalid: {message}")
159+
160+
# lease not pending
161+
if condition_false(result.conditions, "Pending"):
162+
raise LeaseError(
163+
f"Lease {self.name} is not in pending, but it isn't in Ready or "
164+
f"Unsatisfiable state either"
165+
)
166+
167+
# lease released
168+
if condition_present_and_equal(result.conditions, "Ready", "False", "Released"):
169+
raise LeaseError(f"lease {self.name} released")
170+
171+
# Update spinner with appropriate status message
172+
self._update_spinner_status(spinner, result)
173+
174+
# Wait in 1-second increments with tick updates for better UX
175+
for _ in range(5):
176+
await sleep(1)
177+
spinner.tick()
178+
return self
179+
153180
except TimeoutError:
154181
logger.debug(f"Lease {self.name} acquisition timed out after {self.acquisition_timeout} seconds")
155182
raise LeaseError(
@@ -269,3 +296,64 @@ def serve_unix(self):
269296
def monitor(self, threshold: timedelta = timedelta(minutes=5)):
270297
with self.portal.wrap_async_context_manager(self.monitor_async(threshold)):
271298
yield
299+
300+
301+
class LeaseAcquisitionSpinner:
302+
"""Context manager for displaying a spinner during lease acquisition."""
303+
304+
def __init__(self, lease_name: str | None = None):
305+
self.lease_name = lease_name
306+
self.console = Console()
307+
self.spinner = None
308+
self.start_time = None
309+
self._should_show_spinner = self._is_terminal_available() and not self._is_non_interactive()
310+
self._current_message = None
311+
312+
def _is_non_interactive(self) -> bool:
313+
"""Check if the user desires a NONINTERACTIVE environment."""
314+
return os.environ.get("NONINTERACTIVE", "false").lower() in ["true", "1"]
315+
316+
def _is_terminal_available(self) -> bool:
317+
"""Check if we're running in a terminal/TTY."""
318+
return (
319+
hasattr(sys.stdout, 'isatty') and
320+
sys.stdout.isatty() and
321+
hasattr(sys.stderr, 'isatty') and
322+
sys.stderr.isatty()
323+
)
324+
325+
def __enter__(self):
326+
self.start_time = datetime.now()
327+
if self._should_show_spinner:
328+
self.spinner = self.console.status(
329+
f"Acquiring lease {self.lease_name or '...'}...",
330+
spinner="dots",
331+
spinner_style="blue"
332+
)
333+
self.spinner.start()
334+
return self
335+
336+
def __exit__(self, exc_type, exc_val, exc_tb):
337+
if self.spinner:
338+
self.spinner.stop()
339+
340+
def update_status(self, message: str):
341+
"""Update the spinner status message."""
342+
if self.spinner and self._should_show_spinner:
343+
self._current_message = f"[blue]{message}[/blue]"
344+
elapsed = datetime.now() - self.start_time
345+
elapsed_str = str(elapsed).split('.')[0] # Remove microseconds
346+
self.spinner.update(f"{self._current_message} [dim]({elapsed_str})[/dim]")
347+
else:
348+
# Log info message when no console is available
349+
elapsed = datetime.now() - self.start_time
350+
elapsed_str = str(elapsed).split('.')[0] # Remove microseconds
351+
logger.info(f"{message} ({elapsed_str})")
352+
353+
def tick(self):
354+
"""Update the spinner with current elapsed time without changing the message."""
355+
if self.spinner and self._should_show_spinner and self._current_message:
356+
elapsed = datetime.now() - self.start_time
357+
elapsed_str = str(elapsed).split('.')[0] # Remove microseconds
358+
# Use the stored current message and update with new elapsed time
359+
self.spinner.update(f"{self._current_message} [dim]({elapsed_str})[/dim]")

0 commit comments

Comments
 (0)