Created
January 13, 2026 22:33
-
-
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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