Skip to content

Commit c1cd25b

Browse files
authored
Pb draw interval override (#38)
* add env LOGBAR_PROGRESS_OUTPUT_INTERVAL override Signed-off-by: Qubitium <qubitium@modelcloud.ai> * fix test Signed-off-by: Qubitium <qubitium@modelcloud.ai> * fix test Signed-off-by: Qubitium <qubitium@modelcloud.ai> * fix test Signed-off-by: Qubitium <qubitium@modelcloud.ai> --------- Signed-off-by: Qubitium <qubitium@modelcloud.ai>
1 parent 8e15eb9 commit c1cd25b

File tree

7 files changed

+204
-9
lines changed

7 files changed

+204
-9
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
- Built-in styling for progress bar fills, colors, gradients, and head glyphs.
2525
- Animated progress titles with a subtle sweeping highlight.
2626
Set `LOGBAR_ANIMATION=0` to disable the highlight animation.
27+
- Progress output throttling for reducing redraw churn in batch-heavy jobs.
28+
Set `LOGBAR_PROGRESS_OUTPUT_INTERVAL=10` to render every 10 logical updates instead of every update.
2729
- Column-aware table printer with spans, width hints, and `fit` sizing.
2830
- Zero dependencies; works anywhere Python runs.
2931

@@ -99,6 +101,15 @@ for _ in log.pb(500).title("Downloading"):
99101
time.sleep(0.05)
100102
```
101103

104+
When a workload updates progress very frequently, throttle redraw churn globally or per bar:
105+
106+
```py
107+
for _ in log.pb(500, output_interval=10).title("Quantizing"):
108+
time.sleep(0.01)
109+
```
110+
111+
`output_interval=10` means LogBar will emit a fresh snapshot after roughly every 10 logical progress steps, while still forcing the last pending step to render before the bar closes. Set `LOGBAR_PROGRESS_OUTPUT_INTERVAL=10` to apply the same default process-wide.
112+
102113
Manual mode gives full control when you need to interleave logging and redraws:
103114

104115
```py

logbar/logbar.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,17 @@ def _render_progress_stack_locked(precomputed: Optional[dict] = None, columns_hi
319319

320320
if rendered is None:
321321
try:
322-
rendered = pb._render_snapshot(columns)
322+
# Progress bars may throttle redraws. When a bar is not due for
323+
# a fresh snapshot, keep its last rendered line in the stack
324+
# instead of recomputing it opportunistically on another bar's
325+
# redraw.
326+
resolve_rendered = getattr(pb, "_resolve_rendered_line", None)
327+
if callable(resolve_rendered):
328+
rendered = resolve_rendered(columns)
329+
if rendered is None:
330+
rendered = pb._last_rendered_line or ""
331+
else:
332+
rendered = pb._render_snapshot(columns)
323333
except Exception: # pragma: no cover - avoid breaking logging on render issues
324334
rendered = pb._last_rendered_line or ""
325335
else:
@@ -565,10 +575,10 @@ def shared(cls, override_logger: Optional[bool] = False):
565575
return shared_logger
566576

567577

568-
def pb(self, iterable: Iterable):
578+
def pb(self, iterable: Iterable, *, output_interval: Optional[int] = None):
569579
from logbar.progress import ProgressBar
570580

571-
return ProgressBar(iterable, owner=self).attach(self)
581+
return ProgressBar(iterable, owner=self, output_interval=output_interval).attach(self)
572582

573583
def spinner(self, title: str = "", *, interval: float = 0.5, tail_length: int = 4):
574584
from logbar.progress import RollingProgressBar

logbar/progress.py

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,27 @@ def _env_animation_enabled() -> bool:
8282
return str(value).strip().lower() not in {"0", "false", "off", "no"}
8383

8484

85+
def _normalize_output_interval(value: Optional[Union[str, int]]) -> int:
86+
"""Normalize logical progress render intervals to a safe integer >= 1."""
87+
88+
if value is None:
89+
return 1
90+
91+
try:
92+
normalized = int(str(value).strip())
93+
except (TypeError, ValueError):
94+
return 1
95+
96+
return max(1, normalized)
97+
98+
99+
@lru_cache(maxsize=1)
100+
def _env_progress_output_interval() -> int:
101+
"""Global default for how many logical progress updates occur per render."""
102+
103+
return _normalize_output_interval(os.environ.get("LOGBAR_PROGRESS_OUTPUT_INTERVAL", "1"))
104+
105+
85106
def _fg_256(code: int) -> str:
86107
return f"\033[38;5;{code}m"
87108

@@ -374,7 +395,13 @@ def set_default_style(cls, style: Union[str, ProgressStyle]) -> ProgressStyle:
374395
def default_style(cls) -> ProgressStyle:
375396
return get_progress_style(_DEFAULT_STYLE_NAME)
376397

377-
def __init__(self, iterable: Union[Iterable, int, dict, set], owner: Optional["LogBarType"] = None):
398+
def __init__(
399+
self,
400+
iterable: Union[Iterable, int, dict, set],
401+
owner: Optional["LogBarType"] = None,
402+
*,
403+
output_interval: Optional[int] = None,
404+
):
378405
self._iterating = False # state: in init or active iteration
379406

380407
self._render_mode = RenderMode.AUTO
@@ -398,6 +425,10 @@ def __init__(self, iterable: Union[Iterable, int, dict, set], owner: Optional["L
398425
self.time = time.time()
399426
self._title_animation_start = self.time
400427
self._title_animation_period = 0.1
428+
self._output_interval = _normalize_output_interval(
429+
_env_progress_output_interval() if output_interval is None else output_interval
430+
)
431+
self._last_output_step: Optional[int] = None
401432

402433
self.ui_show_left_steps = True # show [1 of 100] on left side
403434
self.ui_show_left_steps_offset = 0
@@ -424,6 +455,12 @@ def style(self, style: Union[str, ProgressStyle]):
424455
self._style_name = resolved.name
425456
return self
426457

458+
def output_interval(self, interval: int):
459+
"""Render after at least `interval` logical progress updates."""
460+
461+
self._output_interval = _normalize_output_interval(interval)
462+
return self
463+
427464
def fill(self, fill: Union[str, ProgressStyle] = "█", empty: Optional[str] = None):
428465
if isinstance(fill, ProgressStyle) or (isinstance(fill, str) and fill in _PROGRESS_STYLES):
429466
return self.style(fill)
@@ -672,10 +709,50 @@ def detach(self):
672709

673710
return self
674711

675-
def draw(self):
712+
def _render_position(self) -> int:
713+
"""Progress coordinate used for render throttling."""
714+
715+
return self.step()
716+
717+
def _should_render(self, force: bool = False, allow_repeat: bool = False) -> bool:
718+
if force:
719+
return True
720+
721+
if not self._last_rendered_line:
722+
return True
723+
724+
last_output_step = self._last_output_step
725+
if last_output_step is None:
726+
return True
727+
728+
current_step = self._render_position()
729+
if allow_repeat and current_step <= last_output_step:
730+
return True
731+
732+
return (current_step - last_output_step) >= self._output_interval
733+
734+
def _resolve_rendered_line(
735+
self,
736+
columns: int,
737+
force: bool = False,
738+
allow_repeat: bool = False,
739+
) -> Optional[str]:
740+
if not self._should_render(force=force, allow_repeat=allow_repeat):
741+
return None
742+
743+
rendered_line = self._render_snapshot(columns)
744+
self._last_output_step = self._render_position()
745+
return rendered_line
746+
747+
def draw(self, force: bool = False):
748+
# Even skipped draws count as activity. This prevents the background
749+
# refresher from redrawing the same bar immediately and undoing the
750+
# throttle when progress is advancing quickly.
676751
_record_progress_activity()
677752
columns, _ = terminal_size()
678-
rendered_line = self._render_snapshot(columns)
753+
rendered_line = self._resolve_rendered_line(columns, force=force, allow_repeat=True)
754+
if rendered_line is None:
755+
return
679756

680757
render_fn = render_progress_stack if callable(render_progress_stack) else None
681758
context, lock_held = self._render_lock_context()
@@ -976,6 +1053,13 @@ def close(self):
9761053
if self.closed:
9771054
return
9781055

1056+
# Force the last observed progress state to render even when the final
1057+
# step does not land on the configured output interval, e.g. 15 with an
1058+
# interval of 10. Without this, the closing detach would erase the bar
1059+
# before a 100% snapshot is ever emitted.
1060+
if self.step() > 0 and (self._last_output_step is None or self.step() != self._last_output_step):
1061+
self.draw(force=True)
1062+
9791063
self.closed = True
9801064
self.detach()
9811065

@@ -984,7 +1068,10 @@ class RollingProgressBar(ProgressBar):
9841068
"""Indeterminate progress indicator with a rolling highlight."""
9851069

9861070
def __init__(self, owner: Optional["LogBarType"] = None, interval: float = 0.5, tail_length: int = 4):
987-
super().__init__(iterable=1, owner=owner)
1071+
# Spinner animation already has its own wall-clock interval. Keep
1072+
# output throttling at 1 so the phase animation stays smooth unless a
1073+
# caller explicitly changes it later.
1074+
super().__init__(iterable=1, owner=owner, output_interval=1)
9881075
self._interval = max(0.05, float(interval))
9891076
self._tail_length = max(1, int(tail_length))
9901077
self._phase = 0
@@ -1050,6 +1137,9 @@ def _animate_loop(self) -> None:
10501137
def _advance_phase(self) -> None:
10511138
self._phase = (self._phase + 1) % 1_000_000
10521139

1140+
def _render_position(self) -> int:
1141+
return self._phase
1142+
10531143
def _render_snapshot(self, columns: Optional[int] = None) -> str:
10541144
if columns is None:
10551145
columns, _ = terminal_size()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "LogBar"
7-
version = "0.2.3"
7+
version = "0.2.4"
88
description = "A unified Logger and ProgressBar util with zero dependencies."
99
readme = "README.md"
1010
requires-python = ">=3"

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
from pathlib import Path
3+
4+
5+
ROOT = Path(__file__).resolve().parent.parent
6+
7+
if str(ROOT) not in sys.path:
8+
sys.path.insert(0, str(ROOT))

tests/pytest.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
[pytest]
2-
addopts = -s
2+
addopts = -s
3+
pythonpath = ..

tests/test_progress.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# SPDX-License-Identifier: Apache-2.0
44
# Contact: qubitium@modelcloud.ai, x.com/qubitium
55

6+
import os
67
import subprocess
78
import random
89
import re
@@ -15,6 +16,7 @@
1516
from unittest.mock import patch
1617

1718
from logbar import LogBar
19+
from logbar import progress as progress_module
1820
from logbar.progress import ProgressBar, TITLE_HIGHLIGHT_COLOR, ANSI_BOLD_RESET
1921
from logbar.logbar import _active_progress_bars
2022

@@ -68,6 +70,33 @@ def generate_expanding_str_a_to_z():
6870
REVERSED_SAMPLES = reversed(SAMPLES)
6971

7072
class TestProgress(unittest.TestCase):
73+
def setUp(self):
74+
self._saved_env = {
75+
"LOGBAR_ANIMATION": os.environ.get("LOGBAR_ANIMATION"),
76+
"LOGBAR_PROGRESS_OUTPUT_INTERVAL": os.environ.get("LOGBAR_PROGRESS_OUTPUT_INTERVAL"),
77+
}
78+
79+
for key in self._saved_env:
80+
os.environ.pop(key, None)
81+
82+
self._clear_progress_env_caches()
83+
84+
def tearDown(self):
85+
for key, value in self._saved_env.items():
86+
if value is None:
87+
os.environ.pop(key, None)
88+
else:
89+
os.environ[key] = value
90+
91+
self._clear_progress_env_caches()
92+
93+
@staticmethod
94+
def _clear_progress_env_caches():
95+
for helper_name in ("_env_animation_enabled", "_env_progress_output_interval"):
96+
helper = getattr(progress_module, helper_name, None)
97+
cache_clear = getattr(helper, "cache_clear", None)
98+
if callable(cache_clear):
99+
cache_clear()
71100

72101
def test_title_fixed_subtitle_dynamic(self):
73102
pb = log.pb(SAMPLES).title("TITLE:").manual()
@@ -170,6 +199,52 @@ def test_title_animation_respects_logbar_animation_env(self):
170199
)
171200
self.assertEqual(disabled.stdout.strip(), "0")
172201

202+
def test_progress_output_interval_respects_env(self):
203+
cache_clear = progress_module._env_progress_output_interval.cache_clear
204+
205+
with patch.dict(os.environ, {"LOGBAR_PROGRESS_OUTPUT_INTERVAL": "10"}):
206+
cache_clear()
207+
pb = ProgressBar(range(5))
208+
self.assertEqual(pb._output_interval, 10)
209+
with redirect_stdout(StringIO()):
210+
pb.close()
211+
212+
cache_clear()
213+
214+
def test_progress_output_interval_defaults_to_one(self):
215+
cache_clear = progress_module._env_progress_output_interval.cache_clear
216+
217+
with patch.dict(os.environ, {}, clear=True):
218+
cache_clear()
219+
pb = ProgressBar(range(5))
220+
self.assertEqual(pb._output_interval, 1)
221+
with redirect_stdout(StringIO()):
222+
pb.close()
223+
224+
cache_clear()
225+
226+
def test_progress_output_interval_skips_intermediate_draws_but_flushes_final_step(self):
227+
columns = 96
228+
229+
with patch('logbar.progress.terminal_size', return_value=(columns, 24)), \
230+
patch('logbar.logbar.terminal_size', return_value=(columns, 24)):
231+
buffer = StringIO()
232+
with redirect_stdout(buffer):
233+
pb = log.pb(15, output_interval=10).manual()
234+
for step in range(1, 16):
235+
pb.current_iter_step = step
236+
pb.draw()
237+
pb.close()
238+
239+
lines = extract_rendered_lines(buffer.getvalue())
240+
progress_lines = [line for line in lines if "/15]" in line]
241+
242+
self.assertTrue(any("[0/15]" in line for line in progress_lines))
243+
self.assertTrue(any("[10/15]" in line for line in progress_lines))
244+
self.assertTrue(any("[15/15]" in line and "100.0%" in line for line in progress_lines))
245+
self.assertFalse(any("[9/15]" in line for line in progress_lines))
246+
self.assertFalse(any("[14/15]" in line for line in progress_lines))
247+
173248
def test_draw_respects_terminal_width(self):
174249
pb = log.pb(100).title("TITLE").subtitle("SUBTITLE").manual()
175250
pb.current_iter_step = 50

0 commit comments

Comments
 (0)