logbar 0.2.2__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.2
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
@@ -38,6 +38,9 @@ Dynamic: license-file
38
38
  - Stackable progress bars that stay anchored while your logs flow freely.
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
+ 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.
41
44
  - Column-aware table printer with spans, width hints, and `fit` sizing.
42
45
  - Zero dependencies; works anywhere Python runs.
43
46
 
@@ -113,6 +116,15 @@ for _ in log.pb(500).title("Downloading"):
113
116
  time.sleep(0.05)
114
117
  ```
115
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
+
116
128
  Manual mode gives full control when you need to interleave logging and redraws:
117
129
 
118
130
  ```py
@@ -143,7 +155,7 @@ with log.spinner("Loading model") as spinner:
143
155
  warm_up()
144
156
  ```
145
157
 
146
- The rolling bar animates automatically while attached. Close it explicitly with `spinner.close()` if you are not using the context manager.
158
+ The rolling bar animates automatically while attached. Close it explicitly with `spinner.close()` if you are not using the context manager. Set `LOGBAR_ANIMATION=0` to disable the title highlight sweep on progress labels.
147
159
 
148
160
  ### Multiple Progress Bars
149
161
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LogBar
3
- Version: 0.2.2
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
@@ -38,6 +38,9 @@ Dynamic: license-file
38
38
  - Stackable progress bars that stay anchored while your logs flow freely.
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
+ 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.
41
44
  - Column-aware table printer with spans, width hints, and `fit` sizing.
42
45
  - Zero dependencies; works anywhere Python runs.
43
46
 
@@ -113,6 +116,15 @@ for _ in log.pb(500).title("Downloading"):
113
116
  time.sleep(0.05)
114
117
  ```
115
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
+
116
128
  Manual mode gives full control when you need to interleave logging and redraws:
117
129
 
118
130
  ```py
@@ -143,7 +155,7 @@ with log.spinner("Loading model") as spinner:
143
155
  warm_up()
144
156
  ```
145
157
 
146
- The rolling bar animates automatically while attached. Close it explicitly with `spinner.close()` if you are not using the context manager.
158
+ The rolling bar animates automatically while attached. Close it explicitly with `spinner.close()` if you are not using the context manager. Set `LOGBAR_ANIMATION=0` to disable the title highlight sweep on progress labels.
147
159
 
148
160
  ### Multiple Progress Bars
149
161
 
@@ -23,6 +23,9 @@
23
23
  - Stackable progress bars that stay anchored while your logs flow freely.
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
+ 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.
26
29
  - Column-aware table printer with spans, width hints, and `fit` sizing.
27
30
  - Zero dependencies; works anywhere Python runs.
28
31
 
@@ -98,6 +101,15 @@ for _ in log.pb(500).title("Downloading"):
98
101
  time.sleep(0.05)
99
102
  ```
100
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
+
101
113
  Manual mode gives full control when you need to interleave logging and redraws:
102
114
 
103
115
  ```py
@@ -128,7 +140,7 @@ with log.spinner("Loading model") as spinner:
128
140
  warm_up()
129
141
  ```
130
142
 
131
- The rolling bar animates automatically while attached. Close it explicitly with `spinner.close()` if you are not using the context manager.
143
+ The rolling bar animates automatically while attached. Close it explicitly with `spinner.close()` if you are not using the context manager. Set `LOGBAR_ANIMATION=0` to disable the title highlight sweep on progress labels.
132
144
 
133
145
  ### Multiple Progress Bars
134
146
 
@@ -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
@@ -4,6 +4,7 @@
4
4
  # Contact: qubitium@modelcloud.ai, x.com/qubitium
5
5
 
6
6
  import datetime
7
+ import os
7
8
  import re
8
9
  import sys
9
10
  import time
@@ -11,6 +12,7 @@ import threading
11
12
  from contextlib import contextmanager, nullcontext
12
13
  from dataclasses import dataclass, replace
13
14
  from enum import Enum
15
+ from functools import lru_cache
14
16
  from typing import Iterable, Optional, Union, TYPE_CHECKING, Sequence
15
17
  from warnings import warn
16
18
 
@@ -74,6 +76,33 @@ def _iter_ansi_tokens(text: str):
74
76
  i += 1
75
77
 
76
78
 
79
+ @lru_cache(maxsize=1)
80
+ def _env_animation_enabled() -> bool:
81
+ value = os.environ.get("LOGBAR_ANIMATION", "1")
82
+ return str(value).strip().lower() not in {"0", "false", "off", "no"}
83
+
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
+
77
106
  def _fg_256(code: int) -> str:
78
107
  return f"\033[38;5;{code}m"
79
108
 
@@ -366,7 +395,13 @@ class ProgressBar:
366
395
  def default_style(cls) -> ProgressStyle:
367
396
  return get_progress_style(_DEFAULT_STYLE_NAME)
368
397
 
369
- 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
+ ):
370
405
  self._iterating = False # state: in init or active iteration
371
406
 
372
407
  self._render_mode = RenderMode.AUTO
@@ -390,6 +425,10 @@ class ProgressBar:
390
425
  self.time = time.time()
391
426
  self._title_animation_start = self.time
392
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
393
432
 
394
433
  self.ui_show_left_steps = True # show [1 of 100] on left side
395
434
  self.ui_show_left_steps_offset = 0
@@ -416,6 +455,12 @@ class ProgressBar:
416
455
  self._style_name = resolved.name
417
456
  return self
418
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
+
419
464
  def fill(self, fill: Union[str, ProgressStyle] = "█", empty: Optional[str] = None):
420
465
  if isinstance(fill, ProgressStyle) or (isinstance(fill, str) and fill in _PROGRESS_STYLES):
421
466
  return self.style(fill)
@@ -664,10 +709,50 @@ class ProgressBar:
664
709
 
665
710
  return self
666
711
 
667
- 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.
668
751
  _record_progress_activity()
669
752
  columns, _ = terminal_size()
670
- 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
671
756
 
672
757
  render_fn = render_progress_stack if callable(render_progress_stack) else None
673
758
  context, lock_held = self._render_lock_context()
@@ -878,6 +963,8 @@ class ProgressBar:
878
963
  return ''.join(result)
879
964
 
880
965
  def _should_animate_title(self) -> bool:
966
+ if not _env_animation_enabled():
967
+ return False
881
968
  isatty = getattr(sys.stdout, "isatty", None)
882
969
  if not callable(isatty):
883
970
  return False
@@ -966,6 +1053,13 @@ class ProgressBar:
966
1053
  if self.closed:
967
1054
  return
968
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
+
969
1063
  self.closed = True
970
1064
  self.detach()
971
1065
 
@@ -974,7 +1068,10 @@ class RollingProgressBar(ProgressBar):
974
1068
  """Indeterminate progress indicator with a rolling highlight."""
975
1069
 
976
1070
  def __init__(self, owner: Optional["LogBarType"] = None, interval: float = 0.5, tail_length: int = 4):
977
- 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)
978
1075
  self._interval = max(0.05, float(interval))
979
1076
  self._tail_length = max(1, int(tail_length))
980
1077
  self._phase = 0
@@ -1040,6 +1137,9 @@ class RollingProgressBar(ProgressBar):
1040
1137
  def _advance_phase(self) -> None:
1041
1138
  self._phase = (self._phase + 1) % 1_000_000
1042
1139
 
1140
+ def _render_position(self) -> int:
1141
+ return self._phase
1142
+
1043
1143
  def _render_snapshot(self, columns: Optional[int] = None) -> str:
1044
1144
  if columns is None:
1045
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.2"
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,8 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
  # Contact: qubitium@modelcloud.ai, x.com/qubitium
5
5
 
6
+ import os
7
+ import subprocess
6
8
  import random
7
9
  import re
8
10
  import sys
@@ -14,6 +16,7 @@ from time import sleep
14
16
  from unittest.mock import patch
15
17
 
16
18
  from logbar import LogBar
19
+ from logbar import progress as progress_module
17
20
  from logbar.progress import ProgressBar, TITLE_HIGHLIGHT_COLOR, ANSI_BOLD_RESET
18
21
  from logbar.logbar import _active_progress_bars
19
22
 
@@ -67,6 +70,33 @@ SAMPLES = generate_expanding_str_a_to_z()
67
70
  REVERSED_SAMPLES = reversed(SAMPLES)
68
71
 
69
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()
70
100
 
71
101
  def test_title_fixed_subtitle_dynamic(self):
72
102
  pb = log.pb(SAMPLES).title("TITLE:").manual()
@@ -139,6 +169,82 @@ class TestProgress(unittest.TestCase):
139
169
  with redirect_stdout(StringIO()):
140
170
  pb.close()
141
171
 
172
+ def test_title_animation_respects_logbar_animation_env(self):
173
+ script = (
174
+ "from types import SimpleNamespace\n"
175
+ "import sys\n"
176
+ "from logbar.progress import ProgressBar\n"
177
+ "pb = ProgressBar(range(1)).manual()\n"
178
+ "sys.stdout = SimpleNamespace(isatty=lambda: True, write=lambda *_: None, flush=lambda: None)\n"
179
+ "sys.__stdout__.write('1\\n' if pb._should_animate_title() else '0\\n')\n"
180
+ )
181
+
182
+ enabled = subprocess.run(
183
+ [sys.executable, "-c", script],
184
+ cwd="/root/LogBar",
185
+ capture_output=True,
186
+ text=True,
187
+ env={},
188
+ check=True,
189
+ )
190
+ self.assertEqual(enabled.stdout.strip(), "1")
191
+
192
+ disabled = subprocess.run(
193
+ [sys.executable, "-c", script],
194
+ cwd="/root/LogBar",
195
+ capture_output=True,
196
+ text=True,
197
+ env={"LOGBAR_ANIMATION": "0"},
198
+ check=True,
199
+ )
200
+ self.assertEqual(disabled.stdout.strip(), "0")
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
+
142
248
  def test_draw_respects_terminal_width(self):
143
249
  pb = log.pb(100).title("TITLE").subtitle("SUBTITLE").manual()
144
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