|
1 | 1 | import logging |
| 2 | +import os |
| 3 | +import sys |
2 | 4 | from collections.abc import AsyncGenerator, Generator |
3 | 5 | from contextlib import ( |
4 | 6 | ExitStack, |
|
20 | 22 | from anyio.from_thread import BlockingPortal |
21 | 23 | from grpc.aio import AioRpcError, Channel |
22 | 24 | from jumpstarter_protocol import jumpstarter_pb2, jumpstarter_pb2_grpc |
| 25 | +from rich.console import Console |
23 | 26 |
|
24 | 27 | from .exceptions import LeaseError |
25 | 28 | from jumpstarter.client import client_from_path |
@@ -112,44 +115,68 @@ async def request_async(self): |
112 | 115 |
|
113 | 116 | return await self._acquire() |
114 | 117 |
|
| 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 | + |
115 | 129 | async def _acquire(self): |
116 | 130 | """Acquire a lease. |
117 | 131 |
|
118 | 132 | Makes sure the lease is ready, and returns the lease object. |
119 | 133 | """ |
120 | 134 | try: |
121 | 135 | 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 | + |
153 | 180 | except TimeoutError: |
154 | 181 | logger.debug(f"Lease {self.name} acquisition timed out after {self.acquisition_timeout} seconds") |
155 | 182 | raise LeaseError( |
@@ -269,3 +296,64 @@ def serve_unix(self): |
269 | 296 | def monitor(self, threshold: timedelta = timedelta(minutes=5)): |
270 | 297 | with self.portal.wrap_async_context_manager(self.monitor_async(threshold)): |
271 | 298 | 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