Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-prompt user for project name instead of exiting, spinner for package install #309

Merged
merged 4 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
raise EnvironmentError(message)


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

def _validate(slug_name: Optional[str]) -> bool:

Check warning on line 42 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L42

Added line #L42 was not covered by tests
if not slug_name:
log.error("Project name cannot be empty")
return False

Check warning on line 45 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L44-L45

Added lines #L44 - L45 were not covered by tests

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

Check warning on line 49 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L48-L49

Added lines #L48 - L49 were not covered by tests

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

Check warning on line 53 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L52-L53

Added lines #L52 - L53 were not covered by tests

return True

Check warning on line 55 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L55

Added line #L55 was not covered by tests

def _prompt() -> str:
return inquirer.text(

Check warning on line 58 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L57-L58

Added lines #L57 - L58 were not covered by tests
message="Project name (snake_case)",
)

log.info(

Check warning on line 62 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L62

Added line #L62 was not covered by tests
"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

Check warning on line 66 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L66

Added line #L66 was not covered by tests
while not _validate(slug_name):
slug_name = _prompt()

Check warning on line 68 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L68

Added line #L68 was not covered by tests

assert slug_name # appease type checker
return slug_name

Check warning on line 71 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L70-L71

Added lines #L70 - L71 were not covered by tests


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 @@
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()

Check warning on line 123 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L123

Added line #L123 was not covered by tests

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}"

Check warning on line 127 in agentstack/cli/init.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/init.py#L127

Added line #L127 was not covered by tests

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] + "..."

Check warning on line 41 in agentstack/cli/spinner.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/spinner.py#L41

Added line #L41 was not covered by tests

# 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

Check warning on line 77 in agentstack/cli/spinner.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/spinner.py#L75-L77

Added lines #L75 - L77 were not covered by tests

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:

Check warning on line 82 in agentstack/cli/spinner.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/spinner.py#L82

Added line #L82 was not covered by tests
# Skip if message is same as last one
if hasattr(self, '_last_message') and self._last_message == message:
return

Check warning on line 85 in agentstack/cli/spinner.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/spinner.py#L85

Added line #L85 was not covered by tests

self._clear_line()

Check warning on line 87 in agentstack/cli/spinner.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/spinner.py#L87

Added line #L87 was not covered by tests
if color == 'success':
log.success(message)

Check warning on line 89 in agentstack/cli/spinner.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/spinner.py#L89

Added line #L89 was not covered by tests
else:
log.info(message)
sys.stdout.flush()

Check warning on line 92 in agentstack/cli/spinner.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/spinner.py#L91-L92

Added lines #L91 - L92 were not covered by tests

# Store current message
self._last_message = message

Check warning on line 95 in agentstack/cli/spinner.py

View check run for this annotation

Codecov / codecov/patch

agentstack/cli/spinner.py#L95

Added line #L95 was not covered by tests
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')

Check warning on line 31 in agentstack/packaging.py

View check run for this annotation

Codecov / codecov/patch

agentstack/packaging.py#L31

Added line #L31 was not covered by tests

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

Check warning on line 47 in agentstack/packaging.py

View check run for this annotation

Codecov / codecov/patch

agentstack/packaging.py#L47

Added line #L47 was not covered by tests

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

Check warning on line 51 in agentstack/packaging.py

View check run for this annotation

Codecov / codecov/patch

agentstack/packaging.py#L51

Added line #L51 was not covered by tests

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(

Check warning on line 58 in agentstack/packaging.py

View check run for this annotation

Codecov / codecov/patch

agentstack/packaging.py#L57-L58

Added lines #L57 - L58 were not covered by tests
[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(

Check warning on line 65 in agentstack/packaging.py

View check run for this annotation

Codecov / codecov/patch

agentstack/packaging.py#L64-L65

Added lines #L64 - L65 were not covered by tests
[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
Loading