Skip to content

Instantly share code, notes, and snippets.

@brandonhimpfen
Created January 13, 2026 22:33
Show Gist options
  • Select an option

  • Save brandonhimpfen/8afa4260e70820264f00b1062600f34c to your computer and use it in GitHub Desktop.

Select an option

Save brandonhimpfen/8afa4260e70820264f00b1062600f34c to your computer and use it in GitHub Desktop.
A tiny, dependency-free Python progress bar for loops and iterables (supports known totals, unknown totals, and ETA).
#!/usr/bin/env python3
"""
Dependency-free progress bar for Python.
Features:
- Works with a known total OR unknown total.
- Prints ETA when total is known.
- Throttles updates to avoid spamming stdout.
- Safe for long-running loops; finalizes cleanly.
Usage:
for item in progress(items, total=len(items)):
...
for item in progress(streaming_iterable, total=None):
...
"""
from __future__ import annotations
import sys
import time
from typing import Iterable, Iterator, Optional, TypeVar
T = TypeVar("T")
def _format_seconds(seconds: float) -> str:
seconds = max(0, int(seconds))
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
if h:
return f"{h:d}:{m:02d}:{s:02d}"
return f"{m:d}:{s:02d}"
def progress(
iterable: Iterable[T],
*,
total: Optional[int] = None,
desc: str = "",
width: int = 30,
update_every: float = 0.1,
file=sys.stderr,
) -> Iterator[T]:
"""
Wrap an iterable and render a progress bar to `file`.
Args:
iterable: Any iterable.
total: Total count (len) if known; if None, shows count + elapsed only.
desc: Prefix label.
width: Bar width in characters.
update_every: Minimum seconds between renders.
file: Output stream (stderr recommended for CLI tools).
Yields:
Items from the iterable.
"""
start = time.perf_counter()
last_render = 0.0
count = 0
# If total wasn't provided, try to infer it (works for sequences).
if total is None:
try:
total = len(iterable) # type: ignore[arg-type]
except Exception:
total = None
def render(final: bool = False) -> None:
nonlocal last_render
now = time.perf_counter()
if not final and (now - last_render) < update_every:
return
last_render = now
elapsed = now - start
rate = (count / elapsed) if elapsed > 0 else 0.0
prefix = f"{desc} " if desc else ""
if total is None:
msg = f"\r{prefix}{count} | {elapsed:0.1f}s | {rate:0.1f}/s"
else:
pct = (count / total) if total > 0 else 1.0
filled = int(width * pct)
bar = "█" * filled + "░" * (width - filled)
remaining = (total - count)
eta = (remaining / rate) if rate > 0 else 0.0
msg = (
f"\r{prefix}[{bar}] {pct*100:6.2f}% "
f"{count}/{total} | {elapsed:0.1f}s | ETA {_format_seconds(eta)}"
)
# Ensure we overwrite any previous longer line
pad = max(0, 3)
file.write(msg + (" " * pad))
file.flush()
try:
for item in iterable:
yield item
count += 1
render()
render(final=True)
finally:
file.write("\n")
file.flush()
# ---------------------------------------------------------------------------
# Example usage
# ---------------------------------------------------------------------------
if __name__ == "__main__":
# Known total example
for _ in progress(range(200), total=200, desc="Processing"):
time.sleep(0.01)
# Unknown total example (simulating a stream)
def stream():
for i in range(75):
time.sleep(0.02)
yield i
for _ in progress(stream(), total=None, desc="Streaming"):
pass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment