Skip to content

Commit 7a0e2d2

Browse files
committed
fix: address PR review (gemini + CodeQL)
- Restore public previous_value semantics (value before the last update() call); the gate now uses a separate private _last_drawn_value for its pixel check (gemini: backward compatibility). - Only back off _gate_step once calibrated, preventing exponential growth of the step before calibration (gemini: performance). - Update gate-step tests for the calibrated back-off + add an uncalibrated no-growth test. - Avoid mixed import of progressbar in tests; comment bench.py teardown excepts (CodeQL).
1 parent b886563 commit 7a0e2d2

3 files changed

Lines changed: 79 additions & 65 deletions

File tree

benchmarks/bench.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,13 @@ def close(self) -> None:
7575
self.file.flush()
7676
self.file.close()
7777
except Exception:
78+
# Teardown only: the slave fd may already be gone; nothing to do.
7879
pass
7980
self._stop.set()
8081
try:
8182
os.close(self._master)
8283
except OSError:
84+
# Master already closed once the drain thread hit EOF; ignore.
8385
pass
8486
self._thread.join(timeout=1)
8587

progressbar/bar.py

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ class ProgressBarMixinBase(abc.ABC):
8686
value: NumberT
8787
#: Previous progress value
8888
previous_value: types.Optional[NumberT]
89+
#: Value at the last actual redraw (internal; used by the update gate's
90+
#: pixel check, kept separate from the public `previous_value`)
91+
_last_drawn_value: types.Optional[NumberT]
8992
#: The minimum/start value for the progress bar
9093
min_value: NumberT
9194
#: Maximum (and final) value. Beyond this value an error will be raised
@@ -746,6 +749,9 @@ def init(self):
746749
used (again).
747750
"""
748751
self.previous_value = None
752+
# Value at the last actual redraw; used internally by the update gate's
753+
# pixel check (distinct from the public `previous_value`).
754+
self._last_drawn_value = None
749755
self.last_update_time = None
750756
self.start_time = None
751757
self.updates = 0
@@ -945,27 +951,26 @@ def __iter__(self):
945951
return
946952
yield item # first item at value == min_value (matches old code)
947953
value = self.value
948-
# Prime `next_update` so the first item after the initial yield
949-
# always calls `update()`. Time may have advanced during that body
950-
# before the gate is calibrated, and we need the first call to
951-
# set `_last_update_timer` and calibrate. Subsequent iterations
952-
# use the back-off / calibrated gate from `self._next_update`.
953954
next_update = value
954955
update = self.update
955956
for item in iterator:
956957
value += 1
957-
self.value = value # keep bar.value live (== current index)
958958
# Before calibration, call `update()` every iteration and let
959959
# `_needs_update()` make the real redraw decision (exactly as
960960
# the manual path does: `gated_out` is False until calibrated).
961961
# Consulting `_next_update` pre-calibration could skip redraws
962-
# the ungated bar renders, because `_recompute_gate`'s back-off
963-
# grows `_gate_step` geometrically before any real timing
964-
# measurement exists. After calibration the measured step is
965-
# bounded to one min_poll_interval window and is safe to skip.
962+
# the ungated bar renders. After calibration the measured step
963+
# is bounded to one min_poll_interval window and is safe to
964+
# skip. Calling `update()` (rather than pre-setting
965+
# `self.value`) lets it record the prior value in the public
966+
# `previous_value`, preserving its original semantics.
966967
if not self._gate_calibrated or value >= next_update:
967968
update(value)
968969
next_update = self._next_update
970+
else:
971+
# Gated out: keep bar.value live without entering the
972+
# redraw machinery (no `previous_value`/redraw change).
973+
self.value = value
969974
yield item
970975
self.finish()
971976
except GeneratorExit:
@@ -1026,15 +1031,15 @@ def _needs_update(self):
10261031
# There's no terminal-width threshold to compute for an unknown
10271032
# length, so redraw whenever the value advanced (still rate
10281033
# limited by the min_poll_interval check above)
1029-
return self.value != self.previous_value
1034+
return self.value != self._last_drawn_value
10301035

10311036
# Update if value increment is not large enough to
10321037
# add more bars to progressbar (according to current
10331038
# terminal width)
10341039
with contextlib.suppress(Exception):
10351040
divisor: float = self.max_value / self.term_width # type: ignore
10361041
value_divisor = self.value // divisor # type: ignore
1037-
pvalue_divisor = self.previous_value // divisor # type: ignore
1042+
pvalue_divisor = self._last_drawn_value // divisor # type: ignore
10381043
if value_divisor != pvalue_divisor:
10391044
return True
10401045
# No need to redraw yet
@@ -1069,14 +1074,15 @@ def _recompute_gate(self, value: NumberT) -> None:
10691074
self._gate_last_value = value
10701075
# A real timing measurement: the gate can now safely skip calls.
10711076
self._gate_calibrated = True
1072-
else:
1073-
# No redraw happened: we checked too early, so back off (double
1074-
# the step). We deliberately do NOT calibrate here. Activating the
1075-
# gate without a real timing measurement can overshoot
1076-
# `_next_update` and permanently drop redraws the baseline would
1077-
# render. The gate is therefore only ever calibrated by the redraw
1078-
# branch above, which sizes the step from a measured rate bounded
1079-
# to one `min_poll_interval` window.
1077+
elif self._gate_calibrated:
1078+
# No redraw happened: we checked too early, so back off (double the
1079+
# step). Only do this once calibrated — before calibration the
1080+
# gate is inactive and `update()` runs every iteration, so doubling
1081+
# here would grow `_gate_step` exponentially with the iteration
1082+
# count for no benefit (the value is never consulted). We also
1083+
# never calibrate in this branch: activating the gate without a
1084+
# real timing measurement can overshoot `_next_update` and drop
1085+
# redraws the baseline would render.
10801086
self._gate_step = max(1, self._gate_step * 2)
10811087
self._gate_last_value = value
10821088
self._next_update = value + self._gate_step
@@ -1110,6 +1116,10 @@ def update(
11101116
else:
11111117
value = typing.cast(NumberT, self.max_value)
11121118

1119+
# `previous_value` keeps its original public meaning: the value
1120+
# before this update() call. The gate uses a separate private
1121+
# `_last_drawn_value` (set on redraw) for its pixel check.
1122+
self.previous_value = self.value
11131123
self.value = value
11141124

11151125
# Save the updated values for dynamic messages (skip the call and the
@@ -1131,12 +1141,12 @@ def update(
11311141
try:
11321142
self._update_parents(value)
11331143
finally:
1134-
# `previous_value` is the value at the last *redraw*, so the
1144+
# `_last_drawn_value` is the value at the last *redraw*, so the
11351145
# pixel check in `_needs_update` still detects boundary
11361146
# crossings even when the gate skipped intermediate updates.
11371147
# Setting it in finally ensures _needs_update() never re-fires
1138-
# due to a stale previous_value after a failed draw attempt.
1139-
self.previous_value = self.value
1148+
# due to a stale value after a failed draw attempt.
1149+
self._last_drawn_value = self.value
11401150
if self._gate_enabled:
11411151
self._recompute_gate(self.value)
11421152

tests/test_fastpath.py

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def test_gate_state_initialized():
205205

206206
def test_recompute_gate_grows_step_when_no_redraw():
207207
bar = progressbar.ProgressBar(max_value=10**9)
208+
bar._gate_calibrated = True # back-off only applies once calibrated
208209
bar._gate_step = 4
209210
bar._gate_last_value = 0
210211
bar._gate_last_timer = 100.0
@@ -214,6 +215,19 @@ def test_recompute_gate_grows_step_when_no_redraw():
214215
assert bar._next_update == 50 + 8
215216

216217

218+
def test_recompute_gate_no_backoff_before_calibration():
219+
# Before calibration the gate is inactive and update() runs every
220+
# iteration; doubling here would grow _gate_step exponentially with the
221+
# iteration count, so the back-off must NOT fire while uncalibrated.
222+
bar = progressbar.ProgressBar(max_value=10**9)
223+
assert bar._gate_calibrated is False
224+
bar._gate_step = 4
225+
bar._gate_last_timer = 100.0
226+
bar._last_update_timer = 100.0 # no redraw happened
227+
bar._recompute_gate(50)
228+
assert bar._gate_step == 4 # unchanged (no exponential growth)
229+
230+
217231
def test_recompute_gate_targets_min_poll_interval():
218232
bar = progressbar.ProgressBar(max_value=10**9)
219233
bar.min_poll_interval = 0.05
@@ -257,34 +271,23 @@ def spy(value):
257271
assert bar.previous_value in drawn
258272

259273

260-
def test_previous_value_not_set_on_skipped_update(monkeypatch):
261-
"""previous_value must only advance when a redraw actually happens.
274+
def test_last_drawn_value_pinned_on_skipped_update(monkeypatch):
275+
"""The gate's pixel reference advances only when a redraw happens.
262276
263-
Old code sequence for update(v):
264-
self.previous_value = self.value # always
265-
self.value = v
266-
if _needs_update(): draw # maybe
277+
`_last_drawn_value` (the private pixel reference used by `_needs_update`)
278+
must stay pinned to the value at the last actual draw, even as later
279+
`update()` calls advance `self.value` without redrawing. The public
280+
`previous_value` keeps its original meaning: the value before the most
281+
recent `update()` call.
267282
268-
New code sequence:
269-
self.value = v
270-
if _needs_update():
271-
draw
272-
self.previous_value = self.value # only on draw
273-
274-
After a draw at value=3 (starting from 0) and two consecutive skips
275-
at value=4 then value=5:
276-
Old: previous_value == 4 (set at the start of update(5))
277-
New: previous_value == 3 (pinned to the drawn value)
278-
279-
The new semantics are required so that _needs_update()'s pixel-threshold
280-
check measures "pixels since last *draw*" rather than "pixels since last
281-
*call*".
283+
After a draw at value=3 (from 0) and two rate-limited skips at 4 then 5:
284+
_last_drawn_value == 3 (pinned to the drawn value, for pixel check)
285+
previous_value == 4 (value before the update(5) call)
282286
"""
283287
from progressbar import bar as bar_module
284288

285-
# Advance the timer a lot during start and first update so it draws,
286-
# then freeze it so subsequent updates are skipped.
287-
# Start is called once; _needs_update reads timer once per update call.
289+
# Freeze-then-advance clock: start at 0, jump to 1.0 so update(3) draws,
290+
# then keep it at 1.0 so subsequent updates are rate-limited (skipped).
288291
_time: list[float] = [0.0]
289292

290293
def timer() -> float:
@@ -295,25 +298,24 @@ def timer() -> float:
295298
bar = progressbar.ProgressBar(max_value=100, fd=RecordingTTY())
296299
bar.start() # _last_update_timer = 0.0
297300

298-
# Advance time far past min_poll_interval (0.05 s) => update(3) will draw.
301+
# Advance time far past min_poll_interval (0.05 s) => update(3) draws.
299302
_time[0] = 1.0
300303
bar.update(3)
301-
# Redraw happened; _last_update_timer is now 1.0.
302-
# Old code: previous_value was 0 (self.value before assignment) pre-draw.
303-
# New code: previous_value set to 3 (self.value after draw).
304-
305-
# Freeze time: delta = 0 => _needs_update() returns False for next calls.
306-
# (No need to touch _last_update_timer; next timer() read returns 1.0.)
307-
308-
bar.update(4) # skipped: old sets prev=3, val=4; new: val=4, prev stays
309-
bar.update(5) # skipped: old sets prev=4, val=5; new: val=5, prev stays
310-
311-
# New semantics: previous_value == 3 (the value at the last actual draw).
312-
# Old semantics: previous_value == 4 (set at the start of update(5)).
313-
assert bar.previous_value == 3, (
314-
f'previous_value should be pinned to last-drawn value (3), '
315-
f'got {bar.previous_value!r}'
304+
assert bar._last_drawn_value == 3 # a redraw happened at value 3
305+
306+
# Time frozen at 1.0: delta == 0 => _needs_update() returns False, so the
307+
# next updates advance self.value but do not redraw.
308+
bar.update(4)
309+
bar.update(5)
310+
311+
# Pixel reference stays at the last drawn value; public previous_value
312+
# tracks the value before the most recent update() call.
313+
assert bar._last_drawn_value == 3, (
314+
f'_last_drawn_value should stay at last-drawn (3), '
315+
f'got {bar._last_drawn_value!r}'
316316
)
317+
assert bar.value == 5 # liveness preserved on the manual path
318+
assert bar.previous_value == 4 # value before update(5)
317319

318320

319321
def test_gate_disabled_skips_recompute():
@@ -572,13 +574,13 @@ def test_iterator_overhead_is_low():
572574
def test_no_color_fast_path_and_ansi():
573575
# Render-cost optimization adds a no-ESC fast path to no_color/len_color.
574576
# It must be identical to the regex path for both plain and ANSI input.
575-
from progressbar import utils
577+
utils = progressbar.utils
576578

577-
# Fast path (no ESC byte) returned unchanged, str and bytes.
579+
# Fast path (no ESC byte): returned unchanged, str and bytes.
578580
assert utils.no_color('plain text') == 'plain text'
579581
assert utils.no_color(b'plain bytes') == b'plain bytes'
580582
assert utils.len_color('plain') == 5
581-
# Regex path (ANSI present) escape sequences stripped, str and bytes.
583+
# Regex path (ANSI present): escape sequences stripped, str and bytes.
582584
assert utils.no_color('\x1b[31mred\x1b[0m') == 'red'
583585
assert utils.no_color(b'\x1b[31mred\x1b[0m') == b'red'
584586
assert utils.len_color('\x1b[1mbold\x1b[0m') == 4

0 commit comments

Comments
 (0)