Skip to content

Commit ff406ba

Browse files
committed
Display spinner on package install.
1 parent a7ad914 commit ff406ba

File tree

2 files changed

+118
-18
lines changed

2 files changed

+118
-18
lines changed

agentstack/cli/spinner.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import itertools
2+
import shutil
3+
import sys
4+
import threading
5+
import time
6+
from typing import Optional, Literal
7+
8+
from agentstack import log
9+
10+
11+
class Spinner:
12+
def __init__(self, message="Working", delay=0.1):
13+
self.spinner = itertools.cycle(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'])
14+
self.delay = delay
15+
self.message = message
16+
self.running = False
17+
self.spinner_thread = None
18+
self.start_time = None
19+
self._lock = threading.Lock()
20+
self._last_printed_len = 0
21+
self._last_message = ""
22+
23+
def _clear_line(self):
24+
"""Clear the current line in terminal."""
25+
sys.stdout.write('\r' + ' ' * self._last_printed_len + '\r')
26+
sys.stdout.flush()
27+
28+
def spin(self):
29+
while self.running:
30+
with self._lock:
31+
elapsed = time.time() - self.start_time
32+
terminal_width = shutil.get_terminal_size().columns
33+
spinner_char = next(self.spinner)
34+
time_str = f"{elapsed:.1f}s"
35+
36+
# Format: [spinner] Message... [time]
37+
message = f"\r{spinner_char} {self.message}... [{time_str}]"
38+
39+
# Ensure we don't exceed terminal width
40+
if len(message) > terminal_width:
41+
message = message[:terminal_width - 3] + "..."
42+
43+
# Clear previous line and print new one
44+
self._clear_line()
45+
sys.stdout.write(message)
46+
sys.stdout.flush()
47+
self._last_printed_len = len(message)
48+
49+
time.sleep(self.delay)
50+
51+
def __enter__(self):
52+
self.start()
53+
return self
54+
55+
def __exit__(self, exc_type, exc_val, exc_tb):
56+
self.stop()
57+
58+
def start(self):
59+
if not self.running:
60+
self.running = True
61+
self.start_time = time.time()
62+
self.spinner_thread = threading.Thread(target=self.spin)
63+
self.spinner_thread.start()
64+
65+
def stop(self):
66+
if self.running:
67+
self.running = False
68+
if self.spinner_thread:
69+
self.spinner_thread.join()
70+
with self._lock:
71+
self._clear_line()
72+
73+
def update_message(self, message):
74+
"""Update spinner message and ensure clean line."""
75+
with self._lock:
76+
self._clear_line()
77+
self.message = message
78+
79+
def clear_and_log(self, message, color: Literal['success', 'info'] = 'success'):
80+
"""Temporarily clear spinner, print message, and resume spinner.
81+
Skips printing if message is the same as the last message printed."""
82+
with self._lock:
83+
# Skip if message is same as last one
84+
if hasattr(self, '_last_message') and self._last_message == message:
85+
return
86+
87+
self._clear_line()
88+
if color == 'success':
89+
log.success(message)
90+
else:
91+
log.info(message)
92+
sys.stdout.flush()
93+
94+
# Store current message
95+
self._last_message = message

agentstack/packaging.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,44 +24,49 @@
2424
def install(package: str):
2525
"""Install a package with `uv` and add it to pyproject.toml."""
2626

27+
from agentstack.cli.spinner import Spinner
28+
2729
def on_progress(line: str):
2830
if RE_UV_PROGRESS.match(line):
29-
log.info(line.strip())
31+
spinner.clear_and_log(line.strip(), 'info')
3032

3133
def on_error(line: str):
3234
log.error(f"uv: [error]\n {line.strip()}")
33-
34-
log.info(f"Installing {package}")
35-
_wrap_command_with_callbacks(
36-
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
37-
on_progress=on_progress,
38-
on_error=on_error,
39-
)
35+
36+
with Spinner(f"Installing {package}") as spinner:
37+
_wrap_command_with_callbacks(
38+
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
39+
on_progress=on_progress,
40+
on_error=on_error,
41+
)
4042

4143

4244
def install_project():
4345
"""Install all dependencies for the user's project."""
4446

47+
from agentstack.cli.spinner import Spinner
48+
4549
def on_progress(line: str):
4650
if RE_UV_PROGRESS.match(line):
47-
log.info(line.strip())
51+
spinner.clear_and_log(line.strip(), 'info')
4852

4953
def on_error(line: str):
5054
log.error(f"uv: [error]\n {line.strip()}")
5155

5256
try:
53-
result = _wrap_command_with_callbacks(
54-
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
55-
on_progress=on_progress,
56-
on_error=on_error,
57-
)
58-
if result is False:
59-
log.info("Retrying uv installation with --no-cache flag...")
60-
_wrap_command_with_callbacks(
61-
[get_uv_bin(), 'pip', 'install', '--no-cache', '--python', '.venv/bin/python', '.'],
57+
with Spinner(f"Installing project dependencies.") as spinner:
58+
result = _wrap_command_with_callbacks(
59+
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
6260
on_progress=on_progress,
6361
on_error=on_error,
6462
)
63+
if result is False:
64+
spinner.clear_and_log("Retrying uv installation with --no-cache flag...", 'info')
65+
_wrap_command_with_callbacks(
66+
[get_uv_bin(), 'pip', 'install', '--no-cache', '--python', '.venv/bin/python', '.'],
67+
on_progress=on_progress,
68+
on_error=on_error,
69+
)
6570
except Exception as e:
6671
log.error(f"Installation failed: {str(e)}")
6772
raise

0 commit comments

Comments
 (0)