From 619d28964d1814713eabc892f4b1df6ef0e7031d Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 03:02:47 +0200 Subject: [PATCH 1/4] refactor(widgets): cooperative super() initialization Convert widgets.py from explicit unbound-parent __init__ calls to cooperative super() initialization. Add a private _WidgetKwargsSink base that terminates the cooperative chains (absorbing stray kwargs third-party widgets have passed for years), and make FormatWidgetMixin, WidthWidgetMixin, VariableMixin and SamplesMixin cooperative. Both WidthWidgetMixin and VariableMixin inherit the sink so it lands after both in every MRO, letting `name` reach VariableMixin in the MultiRangeBar / JobStatusBar diamonds. Every widget constructor that hand-called multiple parents now issues a single super().__init__(...), forwarding its named params as keywords. Drop the abc.ABC base from FormatWidgetMixin / WidthWidgetMixin (neither declares an abstractmethod); WidgetBase keeps ABCMeta. WidgetBase.__init__ now drops any cached `uses_colors` before re-checking, so old-style code that calls parents with different kwargs per pass cannot keep a stale uses_colors=False and silently lose color rendering. Public constructor signatures and rendering output are unchanged (render goldens byte-identical). Flip the previously-strict-xfail test_super_style_widget_constructs_and_renders to passing and add no-double-init and two-phase-color-kwargs regression tests. --- progressbar/widgets.py | 113 +++++++++++++++++++--------------- tests/test_subclass_compat.py | 71 +++++++++++++++++++-- 2 files changed, 129 insertions(+), 55 deletions(-) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index c035f463..47feebf2 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -118,7 +118,7 @@ def _marker(progress, data, width): return wrapper(marker, wrap) -class FormatWidgetMixin(abc.ABC): +class FormatWidgetMixin: """Mixin to format widgets using a formatstring. Variables available: @@ -139,6 +139,9 @@ def __init__( ): self.new_style = new_style self.format = format + # Cooperative: forward remaining kwargs to the next base. ``format`` is + # consumed here and deliberately not forwarded onward. + super().__init__(**kwargs) def get_format( self, @@ -170,7 +173,20 @@ def __call__( raise -class WidthWidgetMixin(abc.ABC): +class _WidgetKwargsSink: + """Terminates cooperative ``__init__`` chains for widgets. + + Absorbs keyword arguments no widget class consumed so a cooperative + chain never reaches ``object.__init__`` with leftovers. Tolerated + silently for backwards compatibility: third-party widgets have passed + stray kwargs to their parents for years. + """ + + def __init__(self, **kwargs: typing.Any) -> None: + super().__init__() + + +class WidthWidgetMixin(_WidgetKwargsSink): """Mixing to make sure widgets are only visible if the screen is within a specified size range so the progressbar fits on both large and small screens. @@ -198,6 +214,7 @@ class WidthWidgetMixin(abc.ABC): def __init__(self, min_width=None, max_width=None, **kwargs: typing.Any): self.min_width = min_width self.max_width = max_width + super().__init__(**kwargs) def check_size(self, progress: ProgressBarMixinBase): max_width = self.max_width @@ -313,6 +330,12 @@ def __init__( merged_gradient.update(gradient_colors) self._gradient_colors = merged_gradient + # Drop any cached ``uses_colors``: old-style code that calls parents + # with different kwargs per pass would otherwise keep a stale + # ``uses_colors=False`` from the first pass (before ``fixed_colors``/ + # ``gradient_colors`` were applied) and silently lose color rendering. + vars(self).pop('uses_colors', None) + if self.uses_colors: self._len = utils.len_color @@ -374,8 +397,7 @@ class FormatLabel(FormatWidgetMixin, WidgetBase): ) def __init__(self, format: str, **kwargs: typing.Any): - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, **kwargs) + super().__init__(format=format, **kwargs) def __call__( self, @@ -408,8 +430,7 @@ def __init__( if '%s' in format and '%(elapsed)s' not in format: format = format.replace('%s', '%(elapsed)s') - FormatLabel.__init__(self, format=format, **kwargs) - TimeSensitiveWidgetBase.__init__(self, **kwargs) + super().__init__(format=format, **kwargs) # This is exposed as a static method for backwards compatibility format_time = staticmethod(utils.format_time) @@ -455,7 +476,7 @@ def __init__( ): self.samples = samples self.key_prefix = (key_prefix or self.__class__.__name__) + '_' - TimeSensitiveWidgetBase.__init__(self, **kwargs) + super().__init__(**kwargs) def get_sample_times(self, progress: ProgressBarMixinBase, data: Data): return progress.extra.setdefault( @@ -522,7 +543,10 @@ def __init__( if '%s' in format and '%(eta)s' not in format: format = format.replace('%s', '%(eta)s') - Timer.__init__(self, **kwargs) + # ``super().__init__`` (Timer) sets ``self.format`` to the + # elapsed-time default; the ETA-specific ``self.format*`` assignments + # below MUST stay after it or ETA renders 'Elapsed Time:' not 'ETA:'. + super().__init__(**kwargs) self.format_not_started = format_not_started self.format_finished = format_finished self.format = format @@ -616,8 +640,7 @@ def __init__( format='Estimated finish time: %(eta)s', **kwargs, ): - ETA.__init__( - self, + super().__init__( format_not_started=format_not_started, format_finished=format_finished, format=format, @@ -643,8 +666,7 @@ def __init__( ): self.exponential_smoothing = exponential_smoothing self.exponential_smoothing_factor = exponential_smoothing_factor - ETA.__init__(self, **kwargs) - SamplesMixin.__init__(self, **kwargs) + super().__init__(**kwargs) def __call__( self, @@ -691,7 +713,7 @@ def __init__( self.smoothing_algorithm = smoothing_algorithm( **self.smoothing_parameters, ) - ETA.__init__(self, **kwargs) + super().__init__(**kwargs) def __call__( self, @@ -731,8 +753,7 @@ def __init__( self.variable = variable self.unit = unit self.prefixes = prefixes - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, **kwargs) + super().__init__(format=format, **kwargs) def __call__( self, @@ -769,8 +790,7 @@ def __init__( self.unit = unit self.prefixes = prefixes self.inverse_format = inverse_format - FormatWidgetMixin.__init__(self, format=format, **kwargs) - TimeSensitiveWidgetBase.__init__(self, **kwargs) + super().__init__(format=format, **kwargs) def _speed(self, value, elapsed): speed = float(value) / elapsed @@ -825,8 +845,7 @@ class AdaptiveTransferSpeed(FileTransferSpeed, SamplesMixin): """Widget for showing the transfer speed based on the last X samples.""" def __init__(self, **kwargs: typing.Any): - FileTransferSpeed.__init__(self, **kwargs) - SamplesMixin.__init__(self, **kwargs) + super().__init__(**kwargs) def __call__( self, @@ -863,7 +882,7 @@ def __init__( self.default = default or markers[0] self.fill_wrap = create_wrapper(fill_wrap) self.fill = create_marker(fill, self.fill_wrap) if fill else None - WidgetBase.__init__(self, **kwargs) + super().__init__(**kwargs) def __call__(self, progress: ProgressBarMixinBase, data: Data, width=None): """Updates the widget to show the next marker or the first marker when @@ -910,8 +929,9 @@ class Counter(FormatWidgetMixin, WidgetBase): """Displays the current count.""" def __init__(self, format='%(value)d', **kwargs: typing.Any): - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, format=format, **kwargs) + # ``format`` is consumed by ``FormatWidgetMixin``; do not leak it into + # the ``WidgetBase`` tail of the cooperative chain. + super().__init__(format=format, **kwargs) def __call__( self, @@ -946,8 +966,7 @@ def __init__( self, format='%(percentage)3d%%', na='N/A%%', **kwargs: typing.Any ): self.na = na - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, format=format, **kwargs) + super().__init__(format=format, **kwargs) def get_format( self, @@ -980,8 +999,8 @@ class SimpleProgress(FormatWidgetMixin, ColoredMixin, WidgetBase): DEFAULT_FORMAT = '%(value_s)s of %(max_value_s)s' def __init__(self, format=DEFAULT_FORMAT, **kwargs: typing.Any): - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, format=format, **kwargs) + super().__init__(format=format, **kwargs) + # ``max_width_cache`` reads ``self.max_width``; keep it after super(). self.max_width_cache = dict() # Pyright isn't happy when we set the key in the initialiser self.max_width_cache['default'] = self.max_width or 0 @@ -1075,7 +1094,7 @@ def __init__( self.fill = string_or_lambda(fill) self.fill_left = fill_left - AutoWidthWidgetBase.__init__(self, **kwargs) + super().__init__(**kwargs) def __call__( self, @@ -1125,8 +1144,7 @@ def __init__( fill - character to use for the empty part of the progress bar fill_left - whether to fill from the left or the right """ - Bar.__init__( - self, + super().__init__( marker=marker, left=left, right=right, @@ -1191,8 +1209,7 @@ def __init__( # the class-level `mapping` so subclasses that declare defaults keep # them when no mapping is passed. self.mapping = dict(self.mapping if mapping is None else mapping) - FormatWidgetMixin.__init__(self, format=format, **kwargs) - WidgetBase.__init__(self, **kwargs) + super().__init__(format=format, **kwargs) def update_mapping(self, **mapping: types.Any): self.mapping.update(mapping) @@ -1211,7 +1228,7 @@ def __call__( ) -class VariableMixin: +class VariableMixin(_WidgetKwargsSink): """Mixin to display a custom user variable.""" def __init__(self, name, **kwargs: typing.Any): @@ -1220,6 +1237,7 @@ def __init__(self, name, **kwargs: typing.Any): if len(name.split()) > 1: raise ValueError('Variable(): argument must be single word') self.name = name + super().__init__(**kwargs) class MultiRangeBar(Bar, VariableMixin): @@ -1234,8 +1252,8 @@ class MultiRangeBar(Bar, VariableMixin): """ def __init__(self, name, markers, **kwargs: typing.Any): - VariableMixin.__init__(self, name) - Bar.__init__(self, **kwargs) + # ``name`` rides through Bar's cooperative chain to VariableMixin. + super().__init__(name=name, **kwargs) self.markers = [string_or_lambda(marker) for marker in markers] def get_values(self, progress: ProgressBarMixinBase, data: Data): @@ -1289,8 +1307,7 @@ def __init__( markers=' ▁▂▃▄▅▆▇█', **kwargs, ): - MultiRangeBar.__init__( - self, + super().__init__( name=name, markers=list(reversed(markers)), **kwargs, @@ -1371,7 +1388,7 @@ def __init__( self.left = string_or_lambda(left) self.right = string_or_lambda(right) - AutoWidthWidgetBase.__init__(self, **kwargs) + super().__init__(**kwargs) def __call__( self, @@ -1412,8 +1429,7 @@ class FormatLabelBar(FormatLabel, Bar): """A bar which has a formatted label in the center.""" def __init__(self, format, **kwargs: typing.Any): - FormatLabel.__init__(self, format, **kwargs) - Bar.__init__(self, **kwargs) + super().__init__(format=format, **kwargs) def __call__( # type: ignore self, @@ -1454,8 +1470,7 @@ class PercentageLabelBar(Percentage, FormatLabelBar): def __init__( self, format='%(percentage)2d%%', na='N/A%%', **kwargs: typing.Any ): - Percentage.__init__(self, format, na=na, **kwargs) - FormatLabelBar.__init__(self, format, **kwargs) + super().__init__(format=format, na=na, **kwargs) def __call__( # type: ignore self, @@ -1479,11 +1494,11 @@ def __init__( **kwargs, ): """Creates a Variable associated with the given name.""" - self.format = format self.width = width self.precision = precision - VariableMixin.__init__(self, name=name) - WidgetBase.__init__(self, **kwargs) + # FormatWidgetMixin (first in the MRO) now sets ``self.format``; + # ``name`` rides the cooperative chain to VariableMixin. + super().__init__(name=name, format=format, **kwargs) def __call__( self, @@ -1531,8 +1546,7 @@ def __init__( **kwargs, ): self.microseconds = microseconds - FormatWidgetMixin.__init__(self, format=format, **kwargs) - TimeSensitiveWidgetBase.__init__(self, **kwargs) + super().__init__(format=format, **kwargs) def __call__( self, @@ -1609,7 +1623,6 @@ def __init__( failure_marker='X', **kwargs, ): - VariableMixin.__init__(self, name) self.name = name # Retained for backward compatibility only; render state now lives in # ``progress.extra`` (see get_job_markers) so a single widget reused by @@ -1628,8 +1641,10 @@ def __init__( self.failure_bg_color = failure_bg_color self.failure_marker = failure_marker - Bar.__init__( - self, + # ``name`` rides Bar's cooperative chain to VariableMixin (which also + # validates it); Bar re-sets left/right/fill from the same values. + super().__init__( + name=name, left=left, right=right, fill=fill, diff --git a/tests/test_subclass_compat.py b/tests/test_subclass_compat.py index 684ce861..3af9934c 100644 --- a/tests/test_subclass_compat.py +++ b/tests/test_subclass_compat.py @@ -136,12 +136,6 @@ def test_super_style_widget_sets_format() -> None: assert widget.format == '%(value)d?' -@pytest.mark.xfail( - strict=True, - reason='FormatWidgetMixin.__init__ does not call super() yet, so a ' - 'single cooperative super().__init__ never reaches WidthWidgetMixin; ' - 'fixed by the cooperative-super() migration (PR 2).', -) def test_super_style_widget_constructs_and_renders() -> None: widget = SuperStyleWidget(min_width=1) assert widget.min_width == 1 @@ -311,3 +305,68 @@ def _final_line(fd: io.StringIO) -> str: # under the deterministic test clock (frozen time -> zero elapsed). GOLDEN_KNOWN_LENGTH_FINAL: str = '100% 10 of 10 |########################|' GOLDEN_UNKNOWN_LENGTH_FINAL: str = '5 Elapsed Time: 0:00:00' + + +# --- post-migration guarantees ---------------------------------------------- + + +def test_no_double_width_mixin_init(monkeypatch: pytest.MonkeyPatch) -> None: + """A cooperative chain must init each base exactly once. + + Pre-migration, ``Timer`` reached ``WidthWidgetMixin.__init__`` twice + (once via ``FormatLabel``/``WidgetBase`` and again via + ``TimeSensitiveWidgetBase``/``WidgetBase``). The single cooperative + chain must run it exactly once. + """ + calls = 0 + original = widgets.WidthWidgetMixin.__init__ + + def counting_init(self, *args: typing.Any, **kwargs: typing.Any) -> None: + nonlocal calls + calls += 1 + original(self, *args, **kwargs) + + monkeypatch.setattr(widgets.WidthWidgetMixin, '__init__', counting_init) + widgets.Timer() + assert calls == 1 + + +class OldStyleTwoPhaseColorWidget( + widgets.FormatWidgetMixin, widgets.WidgetBase +): + """Old-style widget that reaches ``WidgetBase.__init__`` twice. + + The first parent call carries no color kwargs; the second supplies + ``fixed_colors=``. ``uses_colors`` must reflect the *final* state, not + the stale ``False`` cached during the first pass. + """ + + def __init__(self, format: str = '%(value)d', **kwargs: typing.Any): + # First parent call: no color kwargs (would cache uses_colors=False). + widgets.FormatWidgetMixin.__init__(self, format=format) + # Second parent call: colors arrive now. + widgets.WidgetBase.__init__( + self, + fixed_colors=dict(fg_none=widgets.colors.red), + **kwargs, + ) + + def __call__(self, progress, data, format=None): + return widgets.FormatWidgetMixin.__call__(self, progress, data) + + +def test_old_style_two_phase_color_kwargs() -> None: + # Regression: the cached ``uses_colors`` must be dropped between passes + # so late-arriving fixed_colors still enable color rendering. + widget = OldStyleTwoPhaseColorWidget() + assert widget.uses_colors is True + assert widget._len is widgets.utils.len_color + + +def test_super_style_color_kwargs_reach_widget_base() -> None: + # The cooperative path must also route fixed_colors to WidgetBase. + widget = SuperStyleWidget( + fixed_colors=dict(fg_none=widgets.colors.red), + ) + assert widget.uses_colors is True + assert widget._len is widgets.utils.len_color From d27d5c5dd90e80916168cebc73b70dee214b9309 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 03:14:03 +0200 Subject: [PATCH 2/4] refactor(bar): cooperative super() __init__ chain --- progressbar/bar.py | 16 +++++++---- tests/test_subclass_compat.py | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/progressbar/bar.py b/progressbar/bar.py index 38b224bd..3c0fafbb 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -187,7 +187,13 @@ class ProgressBarBase(types.Iterable[NumberT], ProgressBarMixinBase): label: str = '' def __init__(self, **kwargs: typing.Any): - self.index = next(self._index_counter) + # Guard against the cooperative chain (or an old-style subclass + # making several explicit parent __init__ calls) reaching this + # method more than once per instance: `index` keeps its class + # default of -1 until the first construction, so each bar + # consumes exactly one counter value. + if self.index == -1: + self.index = next(self._index_counter) super().__init__(**kwargs) def __repr__(self): @@ -475,7 +481,7 @@ def handle_resize( class ResizableMixin(ProgressBarMixinBase): def __init__(self, term_width: int | None = None, **kwargs: typing.Any): - ProgressBarMixinBase.__init__(self, **kwargs) + super().__init__(**kwargs) self.signal_set = False if term_width: @@ -528,7 +534,7 @@ def __init__( redirect_blank_line: bool = False, **kwargs, ): - DefaultFdMixin.__init__(self, **kwargs) + super().__init__(**kwargs) self.redirect_stderr = redirect_stderr self.redirect_stdout = redirect_stdout # Separate redirected output from the bar with a blank line @@ -675,9 +681,7 @@ def __init__( **kwargs, ): # sourcery skip: low-code-quality """Initializes a progress bar with sane defaults.""" - StdRedirectMixin.__init__(self, **kwargs) - ResizableMixin.__init__(self, **kwargs) - ProgressBarBase.__init__(self, **kwargs) + super().__init__(**kwargs) if not max_value and kwargs.get('maxval') is not None: warnings.warn( 'The usage of `maxval` is deprecated, please use ' diff --git a/tests/test_subclass_compat.py b/tests/test_subclass_compat.py index 3af9934c..892b5fcc 100644 --- a/tests/test_subclass_compat.py +++ b/tests/test_subclass_compat.py @@ -23,11 +23,13 @@ import pytest import progressbar +import progressbar.bar import progressbar.widgets # Alias (not a `from` import) so CodeQL doesn't flag `progressbar` as # imported with both `import` and `import from`. widgets = progressbar.widgets +bar_module = progressbar.bar def _render( @@ -370,3 +372,54 @@ def test_super_style_color_kwargs_reach_widget_base() -> None: ) assert widget.uses_colors is True assert widget._len is widgets.utils.len_color + + +# --- bar.py __init__ chain: cooperative-super() guarantees ------------------ + + +def test_no_double_resizable_mixin_init( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The bar ``__init__`` tower must init each mixin exactly once. + + Pre-migration ``ProgressBar.__init__`` reached + ``ResizableMixin.__init__`` twice: once via + ``StdRedirectMixin`` -> ``DefaultFdMixin.super()`` and again via an + explicit second call. The single cooperative chain must run it + exactly once. + """ + calls = 0 + original = bar_module.ResizableMixin.__init__ + + def counting_init(self, *args: typing.Any, **kwargs: typing.Any) -> None: + nonlocal calls + calls += 1 + original(self, *args, **kwargs) + + monkeypatch.setattr(bar_module.ResizableMixin, '__init__', counting_init) + progressbar.ProgressBar(fd=io.StringIO(), max_value=1, term_width=60) + assert calls == 1 + + +class TripleCallBar(progressbar.ProgressBar): + """Third-party old-style subclass: explicit unbound parent calls. + + Mirrors ``ProgressBar.__init__``'s historic explicit-parent-call + pattern. After the cooperative migration each of these three calls + reaches ``ProgressBarBase.__init__``, so the guarded index + assignment must still consume exactly one index per instance. + """ + + def __init__(self, *args: typing.Any, **kwargs: typing.Any): + bar_module.StdRedirectMixin.__init__(self, *args, **kwargs) + bar_module.ResizableMixin.__init__(self, *args, **kwargs) + bar_module.ProgressBarBase.__init__(self, *args, **kwargs) + + +def test_old_style_triple_call_bar_consumes_one_index() -> None: + first = TripleCallBar(fd=io.StringIO(), max_value=1, term_width=60) + second = TripleCallBar(fd=io.StringIO(), max_value=1, term_width=60) + # Each construction consumes exactly one index despite three explicit + # parent __init__ entry points reaching ProgressBarBase. + assert first.index >= 0 + assert second.index == first.index + 1 From d3793ea396606e860f46b2ea71ac5ef27c05aa37 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 03:26:00 +0200 Subject: [PATCH 3/4] refactor(bar): cooperative update/start/finish dispatch Replace explicit class-qualified parent calls in the update/start/finish chains with cooperative super() dispatch, mirroring the __init__ migration. - ProgressBar._update_parents: three explicit parent update() calls (two of which resolved to the no-op ProgressBarMixinBase.update) collapse to a single super().update(value=value). - ProgressBar.start / .finish: three explicit parent calls each collapse to one super() call. - StdRedirectMixin/DefaultFdMixin/ResizableMixin internal explicit parent calls converted to super(). value is passed by keyword through the chain so the intermediate `*args, **kwargs` and `value=None` signatures interoperate. Ordering note: the SIGWINCH uninstall in ResizableMixin.finish now runs before the stream unwrap in StdRedirectMixin.finish (previously after). The two subsystems are independent, so behavior is unchanged. Add two characterization tests: a super()-style update override is entered exactly once per update() call (guards the collapsed chain against re-dispatch), and finish(end='') threads end through the collapsed chain. --- progressbar/bar.py | 37 +++++++++++-------- tests/test_subclass_compat.py | 69 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/progressbar/bar.py b/progressbar/bar.py index 3c0fafbb..c2f8c6cd 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -339,7 +339,7 @@ def start(self, **kwargs: typing.Any): super().start() def update(self, *args: types.Any, **kwargs: types.Any) -> None: - ProgressBarMixinBase.update(self, *args, **kwargs) + super().update(*args, **kwargs) line: str = converters.to_unicode(self._format_line()) if not self.enable_colors: @@ -363,7 +363,7 @@ def finish( return end = kwargs.pop('end', '\n') - ProgressBarMixinBase.finish(self, *args, **kwargs) + super().finish(*args, **kwargs) if end and not self.line_breaks: self.fd.write(end) @@ -500,7 +500,7 @@ def _handle_resize( self.term_width = w def finish(self): # pragma: no cover - ProgressBarMixinBase.finish(self) + super().finish() if self.signal_set: with contextlib.suppress(Exception): _ResizeRegistry.uninstall(self) @@ -556,7 +556,7 @@ def start(self, *args: typing.Any, **kwargs: typing.Any): self.stderr = utils.streams.stderr utils.streams.start_capturing(self) - DefaultFdMixin.start(self, *args, **kwargs) + super().start(*args, **kwargs) def update(self, value: types.Optional[NumberT] = None): cleared = not self.line_breaks and utils.streams.needs_clear() @@ -567,10 +567,10 @@ def update(self, value: types.Optional[NumberT] = None): if cleared and self.redirect_blank_line: # Keep a blank line between the redirected output and the bar self.fd.write('\n') - DefaultFdMixin.update(self, value=value) + super().update(value=value) def finish(self, end='\n'): - DefaultFdMixin.finish(self, end=end) + super().finish(end=end) utils.streams.stop_capturing(self) if self.redirect_stdout: utils.streams.unwrap_stdout() @@ -1250,9 +1250,11 @@ def _update_variables(self, kwargs): def _update_parents(self, value: ValueT): self.updates += 1 - ResizableMixin.update(self, value=value) - ProgressBarBase.update(self, value=value) - StdRedirectMixin.update(self, value=value) # type: ignore + # Cooperative dispatch through the MRO + # (StdRedirectMixin -> DefaultFdMixin -> ProgressBarMixinBase). The + # `value` is passed by keyword so the intermediate `*args, **kwargs` + # and `value=None` signatures interoperate. + super().update(value=value) # type: ignore # Only flush if something was actually written self.fd.flush() @@ -1293,9 +1295,10 @@ def start( if self.max_value is None: self.max_value = self._DEFAULT_MAXVAL - StdRedirectMixin.start(self, max_value=max_value) - ResizableMixin.start(self, max_value=max_value) - ProgressBarBase.start(self, max_value=max_value) + # Cooperative dispatch through the MRO + # (StdRedirectMixin -> DefaultFdMixin -> ProgressBarMixinBase); + # ResizableMixin/ProgressBarBase define no `start` and are skipped. + super().start(max_value=max_value) # Constructing the default widgets is only done when we know max_value if not self.widgets: @@ -1385,9 +1388,13 @@ def finish(self, end: str = '\n', dirty: bool = False): self.end_time = datetime.now() self.update(self.max_value, force=True) - StdRedirectMixin.finish(self, end=end) - ResizableMixin.finish(self) - ProgressBarBase.finish(self) + # Cooperative dispatch through the MRO + # (StdRedirectMixin -> DefaultFdMixin -> ResizableMixin -> + # ProgressBarMixinBase). Ordering note: the SIGWINCH uninstall in + # ResizableMixin.finish now runs *before* the stream unwrap in + # StdRedirectMixin.finish (previously it ran after). The two + # subsystems are independent, so the observable result is unchanged. + super().finish(end=end) @property def currval(self): diff --git a/tests/test_subclass_compat.py b/tests/test_subclass_compat.py index 892b5fcc..c4910e2e 100644 --- a/tests/test_subclass_compat.py +++ b/tests/test_subclass_compat.py @@ -423,3 +423,72 @@ def test_old_style_triple_call_bar_consumes_one_index() -> None: # parent __init__ entry points reaching ProgressBarBase. assert first.index >= 0 assert second.index == first.index + 1 + + +# --- update/start/finish chain: cooperative-super() guarantees -------------- + + +def test_super_style_update_override_dispatched_once() -> None: + """A super()-style ``update`` override runs exactly once per call. + + The collapsed ``_update_parents`` chain dispatches to the *parent* + mixins via ``super().update(...)``, so it must never re-enter the + subclass's own ``update`` override. A double dispatch through the + chain would bump the counter twice. + """ + calls = 0 + + class CountingBar(progressbar.ProgressBar): + def update( + self, + value: typing.Any = None, + force: bool = False, + **kwargs: typing.Any, + ) -> None: + nonlocal calls + calls += 1 + super().update(value, force=force, **kwargs) + + bar = CountingBar(fd=io.StringIO(), max_value=10, term_width=60) + # start() itself calls update(min_value); ignore those bootstrap calls. + bar.start() + calls = 0 + bar.update(1, force=True) + assert calls == 1 + bar.finish() + + +def test_finish_end_kwarg_threads_through_chain() -> None: + """``finish(end='')`` still threads ``end`` through the collapsed chain. + + ``end`` is popped inside ``DefaultFdMixin.finish`` after the migration; + an empty value must suppress the trailing newline while the default + still writes one. + """ + fd_blank = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd_blank, + max_value=10, + term_width=60, + enable_colors=False, + line_breaks=False, + ) + bar.start() + bar.update(5, force=True) + bar.finish(end='') + assert bar.finished() + assert not fd_blank.getvalue().endswith('\n') + + # Contrast: the default end='\n' still writes the trailing newline. + fd_newline = io.StringIO() + bar2 = progressbar.ProgressBar( + fd=fd_newline, + max_value=10, + term_width=60, + enable_colors=False, + line_breaks=False, + ) + bar2.start() + bar2.update(5, force=True) + bar2.finish() + assert fd_newline.getvalue().endswith('\n') From b2de448942185d743b791d0e401aed254f8bfcf9 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 03:44:03 +0200 Subject: [PATCH 4/4] fix(bar): forward start kwargs through DefaultFdMixin DefaultFdMixin.start accepted **kwargs but called super().start() without them, silently dropping start-time kwargs from downstream mixins in the now-cooperative chain (flagged by review on #320). --- progressbar/bar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/progressbar/bar.py b/progressbar/bar.py index c2f8c6cd..a80501ce 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -336,7 +336,7 @@ def print(self, *args: types.Any, **kwargs: types.Any) -> None: def start(self, **kwargs: typing.Any): os_specific.set_console_mode() - super().start() + super().start(**kwargs) def update(self, *args: types.Any, **kwargs: types.Any) -> None: super().update(*args, **kwargs)