Skip to content

Commit

Permalink
Merge pull request #309 from tcdent/init-qol
Browse files Browse the repository at this point in the history
Re-prompt user for project name instead of exiting, spinner for package install
  • Loading branch information
bboynton97 authored Feb 17, 2025
2 parents 92818a8 + ff406ba commit 3d2c759
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 32 deletions.
52 changes: 38 additions & 14 deletions agentstack/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,41 @@ def require_uv():
raise EnvironmentError(message)


def prompt_slug_name() -> str:
"""Prompt the user for a project name."""

def _validate(slug_name: Optional[str]) -> bool:
if not slug_name:
log.error("Project name cannot be empty")
return False

if not is_snake_case(slug_name):
log.error("Project name must be snake_case")
return False

if os.path.exists(conf.PATH / slug_name):
log.error(f"Project path already exists: {conf.PATH / slug_name}")
return False

return True

def _prompt() -> str:
return inquirer.text(
message="Project name (snake_case)",
)

log.info(
"Provide a project name. This will be used to create a new directory in the "
"current path and will be used as the project name. 🐍 Must be snake_case."
)
slug_name = None
while not _validate(slug_name):
slug_name = _prompt()

assert slug_name # appease type checker
return slug_name


def select_template(slug_name: str, framework: Optional[str] = None) -> TemplateConfig:
"""Let the user select a template from the ones available."""
templates: list[TemplateConfig] = get_all_templates()
Expand Down Expand Up @@ -85,22 +120,11 @@ def init_project(
welcome_message()

if not slug_name:
log.info(
"Provide a project name. This will be used to create a new directory in the "
"current path and will be used as the project name. 🐍 Must be snake_case."
)
slug_name = inquirer.text(
message="Project name (snake_case)",
)

if not slug_name:
raise Exception("Project name cannot be empty")
if not is_snake_case(slug_name):
raise Exception("Project name must be snake_case")
slug_name = prompt_slug_name()

conf.set_path(conf.PATH / slug_name)
if os.path.exists(conf.PATH): # cookiecutter requires the directory to not exist
raise Exception(f"Directory already exists: {conf.PATH}")
# cookiecutter requires the directory to not exist
assert not os.path.exists(conf.PATH), f"Directory already exists: {conf.PATH}"

if use_wizard:
log.debug("Initializing new project with wizard.")
Expand Down
95 changes: 95 additions & 0 deletions agentstack/cli/spinner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import itertools
import shutil
import sys
import threading
import time
from typing import Optional, Literal

from agentstack import log


class Spinner:
def __init__(self, message="Working", delay=0.1):
self.spinner = itertools.cycle(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'])
self.delay = delay
self.message = message
self.running = False
self.spinner_thread = None
self.start_time = None
self._lock = threading.Lock()
self._last_printed_len = 0
self._last_message = ""

def _clear_line(self):
"""Clear the current line in terminal."""
sys.stdout.write('\r' + ' ' * self._last_printed_len + '\r')
sys.stdout.flush()

def spin(self):
while self.running:
with self._lock:
elapsed = time.time() - self.start_time
terminal_width = shutil.get_terminal_size().columns
spinner_char = next(self.spinner)
time_str = f"{elapsed:.1f}s"

# Format: [spinner] Message... [time]
message = f"\r{spinner_char} {self.message}... [{time_str}]"

# Ensure we don't exceed terminal width
if len(message) > terminal_width:
message = message[:terminal_width - 3] + "..."

# Clear previous line and print new one
self._clear_line()
sys.stdout.write(message)
sys.stdout.flush()
self._last_printed_len = len(message)

time.sleep(self.delay)

def __enter__(self):
self.start()
return self

def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()

def start(self):
if not self.running:
self.running = True
self.start_time = time.time()
self.spinner_thread = threading.Thread(target=self.spin)
self.spinner_thread.start()

def stop(self):
if self.running:
self.running = False
if self.spinner_thread:
self.spinner_thread.join()
with self._lock:
self._clear_line()

def update_message(self, message):
"""Update spinner message and ensure clean line."""
with self._lock:
self._clear_line()
self.message = message

def clear_and_log(self, message, color: Literal['success', 'info'] = 'success'):
"""Temporarily clear spinner, print message, and resume spinner.
Skips printing if message is the same as the last message printed."""
with self._lock:
# Skip if message is same as last one
if hasattr(self, '_last_message') and self._last_message == message:
return

self._clear_line()
if color == 'success':
log.success(message)
else:
log.info(message)
sys.stdout.flush()

# Store current message
self._last_message = message
41 changes: 23 additions & 18 deletions agentstack/packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,44 +24,49 @@
def install(package: str):
"""Install a package with `uv` and add it to pyproject.toml."""

from agentstack.cli.spinner import Spinner

def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
log.info(line.strip())
spinner.clear_and_log(line.strip(), 'info')

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

log.info(f"Installing {package}")
_wrap_command_with_callbacks(
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
on_progress=on_progress,
on_error=on_error,
)
with Spinner(f"Installing {package}") as spinner:
_wrap_command_with_callbacks(
[get_uv_bin(), 'add', '--python', '.venv/bin/python', package],
on_progress=on_progress,
on_error=on_error,
)


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

from agentstack.cli.spinner import Spinner

def on_progress(line: str):
if RE_UV_PROGRESS.match(line):
log.info(line.strip())
spinner.clear_and_log(line.strip(), 'info')

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

try:
result = _wrap_command_with_callbacks(
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
on_progress=on_progress,
on_error=on_error,
)
if result is False:
log.info("Retrying uv installation with --no-cache flag...")
_wrap_command_with_callbacks(
[get_uv_bin(), 'pip', 'install', '--no-cache', '--python', '.venv/bin/python', '.'],
with Spinner(f"Installing project dependencies.") as spinner:
result = _wrap_command_with_callbacks(
[get_uv_bin(), 'pip', 'install', '--python', '.venv/bin/python', '.'],
on_progress=on_progress,
on_error=on_error,
)
if result is False:
spinner.clear_and_log("Retrying uv installation with --no-cache flag...", 'info')
_wrap_command_with_callbacks(
[get_uv_bin(), 'pip', 'install', '--no-cache', '--python', '.venv/bin/python', '.'],
on_progress=on_progress,
on_error=on_error,
)
except Exception as e:
log.error(f"Installation failed: {str(e)}")
raise
Expand Down

0 comments on commit 3d2c759

Please sign in to comment.