Fix: Flush remaining accumulated steps in ProgressBar.finish() so show_pos displays full completion
Fixed pallets/click#3571 — 1 line bug-fix.

The Bug
Repo: pallets/click Issue: #3571 Status: submission-failed
Description: Flush remaining accumulated steps in ProgressBar.finish() so show_pos displays full completion
Fix scope: 1 line changed in src/click/_termui_impl.py
Root Cause
The bug lives in click’s ProgressBar class, specifically in the interaction
between update() and finish() in _termui_impl.py.
How click tracks progress internally. The ProgressBar maintains two key
state variables: self.pos (the number of items officially “completed”) and
self._completed_intervals (an accumulator for steps that haven’t been
committed to pos yet). The update_min_steps parameter controls the
rendering cadence — when update(1) is called for each iteration, the step
is added to _completed_intervals. Only when _completed_intervals reaches
the update_min_steps threshold does the code call make_step(), which
adds the accumulated count to self.pos, re-renders the bar, and resets the
accumulator to zero. This batching mechanism was introduced to avoid
expensive terminal redraws on every single iteration.
The edge case. When update_min_steps does not evenly divide the total
iteration length, a remainder accumulates in _completed_intervals after
the last render threshold is hit. In the reproduction case — length=20,
update_min_steps=7 — the sequence is:
- Items 1–7:
_completed_intervalsreaches 7,make_step(7)fires, pos → 7, render shows7/20 - Items 8–14: accumulator reaches 7 again,
make_step(7)fires, pos → 14, render shows14/20 - Items 15–20: only 6 steps accumulate in
_completed_intervals, never reaching the threshold of 7
When the generator loop exits, finish() is called. Here’s the problem:
finish() sets self.finished = True and clears ETA tracking, but it
never flushes the remaining accumulated steps stored in
_completed_intervals. The render_progress() call that immediately
follows in the generator loop uses self.pos (still 14) to build the
show_pos display. The bar itself renders at 100% because the pct
property returns 1.0 when self.finished is True, but format_pos()
uses self.pos directly, producing the misleading output 14/20.
The percentage-based display (show_percent=True, the default when
show_pos is not set) masks this bug because pct short-circuits to 1.0
on finished. But show_pos=True reveals the discrepancy: the bar looks
complete while the position counter shows a stale value. This is a classic
state synchronization bug — self.finished is set to True before
self.pos is updated to reflect the true total of items iterated.
Impact. This affects any user of click.progressbar who enables
show_pos=True with an update_min_steps value that doesn’t evenly divide
their iteration length. The visual output shows an incomplete position
counter despite the bar being full, which is confusing and undermines user
trust in the progress indicator. The fix is a single line added to
finish() that flushes the remaining _completed_intervals into self.pos
before marking the progress bar as finished.
The Fix
The fix is a surgical change — every line is deliberate and scoped to
exactly the problem. In ProgressBar.finish(), the method currently sets
self.finished = True without first committing the pending accumulated
steps. The corrected version calls self.make_step(self._completed_intervals)
before setting the finished flag, ensuring that self.pos reflects the true
total items processed. The accumulator is then reset to zero, maintaining
the invariant that _completed_intervals is clean after a make_step call.
This is a one-line insertion that neither refactors the rendering pipeline
nor alters the public API. It simply closes the gap between the accumulated
state and the committed state at the one point in the lifecycle where no
more update() calls will arrive.
Pattern & Takeaways
Pattern. State accumulation with deferred commit — a common performance
pattern in UI frameworks — is vulnerable to flush-at-teardown bugs. The
accumulator acts as a write-back cache for self.pos, and finish() is the
last-chance flush point. Forgetting to drain the buffer leaves the primary
state variable stale. This is structurally identical to a file write buffer
that isn’t flushed before the file handle is closed, or a metrics counter
that isn’t published before the process exits.
Key lessons.
-
Accumulator semantics must be bounded by lifecycle events. Any batching mechanism that defers state updates must have a corresponding flush at every exit point (normal completion, error, early termination). In click’s case, both
finish()and any exception handler in the generator loop are the potential exit points. -
Property short-circuits can mask state bugs. The fact that
pctreturns1.0unconditionally whenself.finishedisTruehid this bug for users of the default percentage display. Different outputs that derive from different state variables can diverge under the same condition — this is a signal that the state machine has a hole. When reviewing code, check that all output paths derive from the same ground truth state. -
Parameter boundary testing.
update_min_stepsdefines a rendering quantum. Any test suite for a progress bar should includelength % update_min_steps != 0as a mandatory test case — i.e., a total length that leaves a remainder. This is a classic off-by-one-in-time bug: the last batch of iterations is smaller than the threshold and never triggers the flush logic. -
Surgical fix principle confirmed. A single line that flushes state at the right lifecycle point is more reliable than refactoring the accumulation logic, which would touch more code paths and risk introducing new bugs. The fix is narrowly scoped to the
finish()method because that is the unique point where the invariant “all steps are committed” must hold.
Transfer Potential
Varies — edge case fixes are repo-specific in detail but universal in pattern. The minimal-change principle and boundary-condition thinking transfer to any codebase. Reading this post helps recognize similar patterns in your own projects.
Auto-generated from PR #3571. View all patches on GitHub.
References
[1] pallets/click [2] #3571 [3] View all patches