Halo renders animated terminal spinners for long-running CLI operations. pip install halo. Basic: from halo import Halo; spinner = Halo(text="Loading", spinner="dots"); spinner.start(); time.sleep(2); spinner.succeed("Done!"). Context manager: with Halo(text="Processing", spinner="dots") as sp: do_work(). Succeed: sp.succeed("Finished"). Fail: sp.fail("Error"). Warn: sp.warn("Skipped"). Info: sp.info("Note"). Stop custom: sp.stop_and_persist(symbol="★", text="Custom"). Update text: sp.text = "Step 2 of 3". Spinners: dots, dots2–12, line, pipe, star, star2, flip, hamburger, growVertical, growHorizontal, balloon, balloon2, noise, bounce, boxBounce, triangle, arc, circle, squareCorners, circleCorners, squish, toggle, toggle2–13, arrow, arrow2–3, bouncingBar, bouncingBall, smiley, monkey, hearts, clock, earth, moon, runner, pong, shark, weather, christmas, grenade, point, layer. Color: color=“red”|“green”|“yellow”|“blue”|“magenta”|“cyan”|“white”. Indent: Halo(indent=4). Enabled: Halo(enabled=sys.stdout.isatty()) — disable in CI. Stream: defaults to sys.stderr. spinner.clear(). spinner.render(). Decorator: @Halo(text="Loading"). Subprocesses: with Halo("Cloning...") as sp: subprocess.run(["git","clone","..."],[...]). HTTP: with Halo("Fetching"): resp = requests.get(url). Nested: use indent + manual start/stop. Claude Code generates Halo spinner wrappers for CLI tools, deploy scripts, and long-running operations.
CLAUDE.md for Halo
## Halo Stack
- Version: halo >= 0.0.31 | pip install halo
- Basic: with Halo(text="Loading", spinner="dots") as sp: work(); sp.succeed("Done")
- Spinners: dots, clock, earth, moon, pong, arc, bounce, bouncingBall, shark — 70+ styles
- Outcomes: sp.succeed(text) | sp.fail(text) | sp.warn(text) | sp.info(text)
- Custom end: sp.stop_and_persist(symbol="✓", text="Deployed!")
- CI safe: Halo(enabled=sys.stdout.isatty()) — silences in non-TTY
- Decorator: @Halo(text="Processing") wraps entire function
Halo Terminal Spinner Pipeline
# app/spinners.py — Halo spinner utilities for CLI tools and long-running operations
from __future__ import annotations
import subprocess
import sys
import time
from contextlib import contextmanager
from typing import Any, Callable, TypeVar
from halo import Halo
T = TypeVar("T")
# ─────────────────────────────────────────────────────────────────────────────
# 1. Core spinner factory
# ─────────────────────────────────────────────────────────────────────────────
def make_spinner(
text: str = "Working",
spinner: str = "dots",
color: str = "cyan",
indent: int = 0,
enabled: bool | None = None,
) -> Halo:
"""
Create a Halo spinner with sensible defaults.
enabled: None → auto-detect TTY; True/False → force.
"""
is_enabled = sys.stdout.isatty() if enabled is None else enabled
return Halo(
text=text,
spinner=spinner,
color=color,
indent=indent,
enabled=is_enabled,
stream=sys.stderr,
)
@contextmanager
def spin(
text: str = "Working",
spinner: str = "dots",
color: str = "cyan",
success: str | None = None,
failure: str | None = None,
indent: int = 0,
):
"""
Context manager that starts a spinner and auto-succeeds/fails.
Usage:
with spin("Fetching data") as sp:
data = requests.get(url).json()
# → auto-calls sp.succeed() on exit
with spin("Uploading", failure="Upload failed") as sp:
upload()
"""
sp = make_spinner(text, spinner=spinner, color=color, indent=indent)
sp.start()
try:
yield sp
sp.succeed(success or text)
except Exception as exc:
sp.fail(failure or f"Failed: {exc}")
raise
# ─────────────────────────────────────────────────────────────────────────────
# 2. Step runner — multi-step CLI pipelines
# ─────────────────────────────────────────────────────────────────────────────
def run_steps(
steps: list[tuple[str, Callable[[], Any]]],
spinner: str = "dots",
color: str = "cyan",
stop_on_error: bool = True,
) -> list[dict[str, Any]]:
"""
Run a sequence of labeled steps, showing a spinner for each.
steps: [("Downloading", download_fn), ("Installing", install_fn), ...]
Returns list of {"step", "ok", "result"|"error"} dicts.
"""
results = []
for label, fn in steps:
sp = make_spinner(label, spinner=spinner, color=color)
sp.start()
try:
result = fn()
sp.succeed(label)
results.append({"step": label, "ok": True, "result": result})
except Exception as exc:
sp.fail(f"{label}: {exc}")
results.append({"step": label, "ok": False, "error": str(exc)})
if stop_on_error:
break
return results
def step(label: str, fn: Callable[[], T], spinner: str = "dots") -> T:
"""
Run a single labeled function with a spinner.
Returns fn()'s return value.
Raises on failure after showing the fail state.
"""
with spin(label, spinner=spinner) as sp:
result = fn()
return result
# ─────────────────────────────────────────────────────────────────────────────
# 3. Subprocess helpers
# ─────────────────────────────────────────────────────────────────────────────
def run_command(
cmd: list[str],
text: str | None = None,
check: bool = True,
capture_output: bool = True,
spinner: str = "dots",
) -> subprocess.CompletedProcess:
"""
Run a subprocess with a spinner.
text: spinner label; defaults to the command string.
"""
label = text or " ".join(cmd)
sp = make_spinner(label, spinner=spinner)
sp.start()
try:
result = subprocess.run(
cmd,
check=check,
capture_output=capture_output,
text=True,
)
sp.succeed(label)
return result
except subprocess.CalledProcessError as exc:
sp.fail(f"{label} (exit {exc.returncode})")
raise
def run_commands(
commands: list[tuple[str, list[str]]],
stop_on_error: bool = True,
) -> bool:
"""
Run a sequence of (label, command) pairs with spinners.
Returns True if all succeeded.
"""
for label, cmd in commands:
sp = make_spinner(label)
sp.start()
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
sp.succeed(label)
except subprocess.CalledProcessError as exc:
sp.fail(f"{label} (exit {exc.returncode})")
if stop_on_error:
return False
return True
# ─────────────────────────────────────────────────────────────────────────────
# 4. Nested spinners (indented)
# ─────────────────────────────────────────────────────────────────────────────
class NestedProgress:
"""
Two-level spinner: a parent task and child sub-tasks.
Usage:
with NestedProgress("Deploying") as p:
p.sub("Building image")
build()
p.sub_done()
p.sub("Pushing to registry")
push()
p.sub_done()
"""
def __init__(self, label: str, spinner: str = "dots", color: str = "cyan"):
self._label = label
self._spinner = spinner
self._color = color
self._parent: Halo | None = None
self._child: Halo | None = None
def __enter__(self) -> "NestedProgress":
self._parent = make_spinner(self._label, spinner=self._spinner, color=self._color)
self._parent.start()
return self
def sub(self, text: str) -> None:
"""Start a child spinner (indented 4 spaces)."""
if self._child:
self._child.succeed(self._child.text or "")
self._child = make_spinner(text, spinner=self._spinner, color="white", indent=4)
self._child.start()
def sub_done(self, text: str | None = None) -> None:
"""Succeed the current child spinner."""
if self._child:
self._child.succeed(text or self._child.text or "")
self._child = None
def sub_fail(self, text: str | None = None) -> None:
if self._child:
self._child.fail(text or self._child.text or "")
self._child = None
def __exit__(self, exc_type, exc_val, exc_tb):
if self._child:
if exc_type:
self._child.fail()
else:
self._child.succeed()
if self._parent:
if exc_type:
self._parent.fail(f"{self._label}: failed")
else:
self._parent.succeed(self._label)
return False
# ─────────────────────────────────────────────────────────────────────────────
# 5. Spinner styles catalog
# ─────────────────────────────────────────────────────────────────────────────
SPINNER_STYLES: dict[str, str] = {
"default": "dots",
"simple": "line",
"clock": "clock",
"earth": "earth",
"moon": "moon",
"pong": "pong",
"bounce": "bouncingBall",
"shark": "shark",
"hearts": "hearts",
"arc": "arc",
"weather": "weather",
"runner": "runner",
}
def demo_spinners(duration: float = 1.0) -> None:
"""Show each built-in spinner style briefly."""
for name, style in SPINNER_STYLES.items():
sp = make_spinner(f"Style: {name}", spinner=style)
sp.start()
time.sleep(duration)
sp.succeed(f"Style: {name}")
# ─────────────────────────────────────────────────────────────────────────────
# 6. HTTP helpers
# ─────────────────────────────────────────────────────────────────────────────
def http_get(url: str, text: str | None = None, **kwargs) -> Any:
"""GET a URL with a spinner. Returns response.json() on 200."""
try:
import requests
except ImportError as exc:
raise ImportError("pip install requests") from exc
label = text or f"GET {url}"
sp = make_spinner(label)
sp.start()
try:
resp = requests.get(url, timeout=kwargs.pop("timeout", 30), **kwargs)
resp.raise_for_status()
sp.succeed(label)
return resp.json() if "json" in resp.headers.get("content-type", "") else resp.text
except Exception as exc:
sp.fail(f"{label}: {exc}")
raise
# ─────────────────────────────────────────────────────────────────────────────
# 7. Typer / Click integration
# ─────────────────────────────────────────────────────────────────────────────
def typer_spin(text: str, spinner: str = "dots"):
"""
Decorator for Typer/Click commands that wraps the function body in a spinner.
Usage:
@app.command()
@typer_spin("Deploying app")
def deploy(name: str):
run_deploy(name)
"""
import functools
def decorator(fn: Callable) -> Callable:
@functools.wraps(fn)
def wrapper(*args, **kwargs):
with spin(text, spinner=spinner):
return fn(*args, **kwargs)
return wrapper
return decorator
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("=== Basic spinner ===")
with spin("Loading config", success="Config loaded") as sp:
time.sleep(0.5)
print("\n=== Step runner ===")
results = run_steps([
("Checking environment", lambda: time.sleep(0.3)),
("Downloading assets", lambda: time.sleep(0.4)),
("Building project", lambda: time.sleep(0.3)),
])
for r in results:
print(f" {r['step']}: {'OK' if r['ok'] else 'FAIL'}")
print("\n=== Nested progress ===")
with NestedProgress("Full deploy pipeline") as p:
p.sub("Building Docker image")
time.sleep(0.3)
p.sub_done()
p.sub("Pushing to registry")
time.sleep(0.3)
p.sub_done()
p.sub("Restarting services")
time.sleep(0.2)
p.sub_done()
print("\n=== Manual succeed/fail/warn/info ===")
sp = make_spinner("Checking database")
sp.start()
time.sleep(0.3)
sp.succeed("Database reachable")
sp2 = make_spinner("Running migration")
sp2.start()
time.sleep(0.3)
sp2.warn("Migration already applied — skipped")
sp3 = make_spinner("Validating schema")
sp3.start()
time.sleep(0.2)
sp3.info("Schema version: 42")
print("\n=== stop_and_persist ===")
sp4 = make_spinner("Deploying to production")
sp4.start()
time.sleep(0.4)
sp4.stop_and_persist(symbol="🚀", text="Deployed to production!")
print("\n=== Demo spinners (0.5s each) ===")
for name, style in list(SPINNER_STYLES.items())[:4]:
sp = make_spinner(f"Style: {name}", spinner=style)
sp.start()
time.sleep(0.5)
sp.succeed(f"Style: {name}")
For the yaspin alternative — yaspin is similar to Halo but supports a wider range of spinner styles through the yaspin.spinners.Spinners enum, has a SigIntProof context that suppresses keyboard interrupts during cleanup, and uses a thread-based approach where Halo uses a separate thread too but yaspin exposes yaspin.api.Yaspin.write() for printing lines without breaking the spinner animation; both are good choices, with Halo being slightly simpler API and yaspin offering more spinner customization. For the rich.status alternative — rich.Status integrates directly into rich markup, supports nested Console.status() contexts cleanly, and inherits all of rich’s colour and styling; Halo works independently of rich and outputs to stderr by default, which is useful when stdout must stay clean for piping — choose rich.status when your CLI already uses rich, Halo when you want a standalone lightweight spinner. The Claude Skills 360 bundle includes Halo skill sets covering Halo() constructor with text/spinner/color/indent, succeed()/fail()/warn()/info() outcomes, stop_and_persist() custom symbol, context manager usage, make_spinner() factory, spin() context manager with auto-result, run_steps() multi-step pipeline, step() single labeled operation, run_command()/run_commands() subprocess wrappers, NestedProgress two-level indented spinners, http_get() with spinner, and typer_spin() decorator. Start with the free tier to try terminal spinner code generation.