logbar 0.2.3__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LogBar
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: A unified Logger and ProgressBar util with zero dependencies.
5
5
  Author-email: ModelCloud <qubitium@modelcloud.ai>
6
6
  License-Expression: Apache-2.0
@@ -39,6 +39,8 @@ Dynamic: license-file
39
39
  - Built-in styling for progress bar fills, colors, gradients, and head glyphs.
40
40
  - Animated progress titles with a subtle sweeping highlight.
41
41
  Set `LOGBAR_ANIMATION=0` to disable the highlight animation.
42
+ - Progress output throttling for reducing redraw churn in batch-heavy jobs.
43
+ Set `LOGBAR_PROGRESS_OUTPUT_INTERVAL=10` to render every 10 logical updates instead of every update.
42
44
  - Column-aware table printer with spans, width hints, and `fit` sizing.
43
45
  - Zero dependencies; works anywhere Python runs.
44
46
 
@@ -114,6 +116,15 @@ for _ in log.pb(500).title("Downloading"):
114
116
  time.sleep(0.05)
115
117
  ```
116
118
 
119
+ When a workload updates progress very frequently, throttle redraw churn globally or per bar:
120
+
121
+ ```py
122
+ for _ in log.pb(500, output_interval=10).title("Quantizing"):
123
+ time.sleep(0.01)
124
+ ```
125
+
126
+ `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.
127
+
117
128
  Manual mode gives full control when you need to interleave logging and redraws:
118
129
 
119
130
  ```py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LogBar
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: A unified Logger and ProgressBar util with zero dependencies.
5
5
  Author-email: ModelCloud <qubitium@modelcloud.ai>
6
6
  License-Expression: Apache-2.0
@@ -39,6 +39,8 @@ Dynamic: license-file
39
39
  - Built-in styling for progress bar fills, colors, gradients, and head glyphs.
40
40
  - Animated progress titles with a subtle sweeping highlight.
41
41
  Set `LOGBAR_ANIMATION=0` to disable the highlight animation.
42
+ - Progress output throttling for reducing redraw churn in batch-heavy jobs.
43
+ Set `LOGBAR_PROGRESS_OUTPUT_INTERVAL=10` to render every 10 logical updates instead of every update.
42
44
  - Column-aware table printer with spans, width hints, and `fit` sizing.
43
45
  - Zero dependencies; works anywhere Python runs.
44
46
 
@@ -114,6 +116,15 @@ for _ in log.pb(500).title("Downloading"):
114
116
  time.sleep(0.05)
115
117
  ```
116
118
 
119
+ When a workload updates progress very frequently, throttle redraw churn globally or per bar:
120
+
121
+ ```py
122
+ for _ in log.pb(500, output_interval=10).title("Quantizing"):
123
+ time.sleep(0.01)
124
+ ```
125
+
126
+ `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.
127
+
117
128
  Manual mode gives full control when you need to interleave logging and redraws:
118
129
 
119
130
  ```py
@@ -24,6 +24,8 @@
24
24
  - Built-in styling for progress bar fills, colors, gradients, and head glyphs.
25
25
  - Animated progress titles with a subtle sweeping highlight.
26
26
  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.
27
29
  - Column-aware table printer with spans, width hints, and `fit` sizing.
28
30
  - Zero dependencies; works anywhere Python runs.
29
31
 
@@ -99,6 +101,15 @@ for _ in log.pb(500).title("Downloading"):
99
101
  time.sleep(0.05)
100
102
  ```
101
103
 
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
+
102
113
  Manual mode gives full control when you need to interleave logging and redraws:
103
114
 
104
115
  ```py
@@ -319,7 +319,17 @@ def _render_progress_stack_locked(precomputed: Optional[dict] = None, columns_hi
319
319
 
320
320
  if rendered is None:
321
321
  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)
323
333
  except Exception: # pragma: no cover - avoid breaking logging on render issues
324
334
  rendered = pb._last_rendered_line or ""
325
335
  else:
@@ -565,10 +575,10 @@ class LogBar(logging.Logger):
565
575
  return shared_logger
566
576
 
567
577
 
568
- def pb(self, iterable: Iterable):
578
+ def pb(self, iterable: Iterable, *, output_interval: Optional[int] = None):
569
579
  from logbar.progress import ProgressBar
570
580
 
571
- return ProgressBar(iterable, owner=self).attach(self)
581
+ return ProgressBar(iterable, owner=self, output_interval=output_interval).attach(self)
572
582
 
573
583
  def spinner(self, title: str = "", *, interval: float = 0.5, tail_length: int = 4):
574
584
  from logbar.progress import RollingProgressBar
@@ -82,6 +82,27 @@ def _env_animation_enabled() -> bool:
82
82
  return str(value).strip().lower() not in {"0", "false", "off", "no"}
83
83
 
84
84
 
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
+
85
106
  def _fg_256(code: int) -> str:
86
107
  return f"\033[38;5;{code}m"
87
108
 
@@ -374,7 +395,13 @@ class ProgressBar:
374
395
  def default_style(cls) -> ProgressStyle:
375
396
  return get_progress_style(_DEFAULT_STYLE_NAME)
376
397
 
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
+ ):
378
405
  self._iterating = False # state: in init or active iteration
379
406
 
380
407
  self._render_mode = RenderMode.AUTO
@@ -398,6 +425,10 @@ class ProgressBar:
398
425
  self.time = time.time()
399
426
  self._title_animation_start = self.time
400
427
  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
401
432
 
402
433
  self.ui_show_left_steps = True # show [1 of 100] on left side
403
434
  self.ui_show_left_steps_offset = 0
@@ -424,6 +455,12 @@ class ProgressBar:
424
455
  self._style_name = resolved.name
425
456
  return self
426
457
 
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
+
427
464
  def fill(self, fill: Union[str, ProgressStyle] = "█", empty: Optional[str] = None):
428
465
  if isinstance(fill, ProgressStyle) or (isinstance(fill, str) and fill in _PROGRESS_STYLES):
429
466
  return self.style(fill)
@@ -672,10 +709,50 @@ class ProgressBar:
672
709
 
673
710
  return self
674
711
 
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.
676
751
  _record_progress_activity()
677
752
  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
679
756
 
680
757
  render_fn = render_progress_stack if callable(render_progress_stack) else None
681
758
  context, lock_held = self._render_lock_context()
@@ -976,6 +1053,13 @@ class ProgressBar:
976
1053
  if self.closed:
977
1054
  return
978
1055
 
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
+
979
1063
  self.closed = True
980
1064
  self.detach()
981
1065
 
@@ -984,7 +1068,10 @@ class RollingProgressBar(ProgressBar):
984
1068
  """Indeterminate progress indicator with a rolling highlight."""
985
1069
 
986
1070
  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)
988
1075
  self._interval = max(0.05, float(interval))
989
1076
  self._tail_length = max(1, int(tail_length))
990
1077
  self._phase = 0
@@ -1050,6 +1137,9 @@ class RollingProgressBar(ProgressBar):
1050
1137
  def _advance_phase(self) -> None:
1051
1138
  self._phase = (self._phase + 1) % 1_000_000
1052
1139
 
1140
+ def _render_position(self) -> int:
1141
+ return self._phase
1142
+
1053
1143
  def _render_snapshot(self, columns: Optional[int] = None) -> str:
1054
1144
  if columns is None:
1055
1145
  columns, _ = terminal_size()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "LogBar"
7
- version = "0.2.3"
7
+ version = "0.2.4"
8
8
  description = "A unified Logger and ProgressBar util with zero dependencies."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3"
@@ -3,6 +3,7 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
  # Contact: qubitium@modelcloud.ai, x.com/qubitium
5
5
 
6
+ import os
6
7
  import subprocess
7
8
  import random
8
9
  import re
@@ -15,6 +16,7 @@ from time import sleep
15
16
  from unittest.mock import patch
16
17
 
17
18
  from logbar import LogBar
19
+ from logbar import progress as progress_module
18
20
  from logbar.progress import ProgressBar, TITLE_HIGHLIGHT_COLOR, ANSI_BOLD_RESET
19
21
  from logbar.logbar import _active_progress_bars
20
22
 
@@ -68,6 +70,33 @@ SAMPLES = generate_expanding_str_a_to_z()
68
70
  REVERSED_SAMPLES = reversed(SAMPLES)
69
71
 
70
72
  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()
71
100
 
72
101
  def test_title_fixed_subtitle_dynamic(self):
73
102
  pb = log.pb(SAMPLES).title("TITLE:").manual()
@@ -170,6 +199,52 @@ class TestProgress(unittest.TestCase):
170
199
  )
171
200
  self.assertEqual(disabled.stdout.strip(), "0")
172
201
 
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
+
173
248
  def test_draw_respects_terminal_width(self):
174
249
  pb = log.pb(100).title("TITLE").subtitle("SUBTITLE").manual()
175
250
  pb.current_iter_step = 50
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes