From ac543ccac2c73e45389ce386b0fc27f466033ef8 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 16:34:10 +0200 Subject: [PATCH 01/24] test: add backwards-compatibility guardrails API-surface snapshot (public names + parameter names/kinds across 14 modules, stable across Python versions), subclass-compat suite covering old-style explicit-parent-call and cooperative-super() third-party widgets and bars, and exact render goldens. The super()-style width kwargs test is a strict xfail documenting the known chain break that the cooperative-super() migration will fix. --- tests/api_surface_snapshot.json | 533 ++++++++++++++++++++++++++++++++ tests/test_api_surface.py | 148 +++++++++ tests/test_subclass_compat.py | 309 ++++++++++++++++++ 3 files changed, 990 insertions(+) create mode 100644 tests/api_surface_snapshot.json create mode 100644 tests/test_api_surface.py create mode 100644 tests/test_subclass_compat.py diff --git a/tests/api_surface_snapshot.json b/tests/api_surface_snapshot.json new file mode 100644 index 00000000..baa0b147 --- /dev/null +++ b/tests/api_surface_snapshot.json @@ -0,0 +1,533 @@ +{ + "progressbar": { + "AbsoluteETA": "class(format_not_started=?, format_finished=?, format=?, **kwargs)", + "AdaptiveETA": "class(exponential_smoothing=?, exponential_smoothing_factor=?, **kwargs)", + "AdaptiveTransferSpeed": "class(**kwargs)", + "AnimatedMarker": "class(markers=?, default=?, fill=?, marker_wrap=?, fill_wrap=?, **kwargs)", + "Bar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", + "BouncingBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", + "Counter": "class(format=?, **kwargs)", + "CurrentTime": "class(format=?, microseconds=?, **kwargs)", + "DataSize": "class(variable=?, format=?, unit=?, prefixes=?, **kwargs)", + "DataTransferBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", + "DoubleExponentialMovingAverage": "class(alpha=?)", + "DynamicMessage": "class(name, format=?, width=?, precision=?, **kwargs)", + "ETA": "class(format_not_started=?, format_finished=?, format=?, format_zero=?, format_na=?, **kwargs)", + "ExponentialMovingAverage": "class(alpha=?)", + "FastProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", + "FileTransferSpeed": "class(format=?, inverse_format=?, unit=?, prefixes=?, **kwargs)", + "FormatCustomText": "class(format, mapping=?, **kwargs)", + "FormatLabel": "class(format, **kwargs)", + "FormatLabelBar": "class(format, **kwargs)", + "GranularBar": "class(markers=?, left=?, right=?, **kwargs)", + "JobStatusBar": "class(name, left=?, right=?, fill=?, fill_left=?, success_fg_color=?, success_bg_color=?, success_marker=?, failure_fg_color=?, failure_bg_color=?, failure_marker=?, **kwargs)", + "LineOffsetStreamWrapper": "class(lines=?, stream=?)", + "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, **progressbar_kwargs)", + "MultiProgressBar": "class(name, markers=?, **kwargs)", + "MultiRangeBar": "class(name, markers, **kwargs)", + "NullBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", + "Percentage": "class(format=?, na=?, **kwargs)", + "PercentageLabelBar": "class(format=?, na=?, **kwargs)", + "ProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", + "ReverseBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, **kwargs)", + "RotatingMarker": "class(markers=?, default=?, fill=?, marker_wrap=?, fill_wrap=?, **kwargs)", + "SimpleProgress": "class(format=?, **kwargs)", + "SmoothingAlgorithm": "class(**kwargs)", + "SmoothingETA": "class(smoothing_algorithm=?, smoothing_parameters=?, **kwargs)", + "SortKey": "class(*values)", + "Timer": "class(format=?, **kwargs)", + "UnknownLength": "class()", + "Variable": "class(name, format=?, width=?, precision=?, **kwargs)", + "VariableMixin": "class(name, **kwargs)", + "__author__": "str", + "__version__": "str", + "len_color": "callable(value)", + "progressbar": "callable(iterator, min_value=?, max_value=?, widgets=?, prefix=?, suffix=?, fast=?, **kwargs)", + "streams": "StreamWrapper" + }, + "progressbar.algorithms": { + "DoubleExponentialMovingAverage": "class(alpha=?)", + "ExponentialMovingAverage": "class(alpha=?)", + "SmoothingAlgorithm": "class(**kwargs)", + "annotations": "_Feature", + "timedelta": "classsignature-unavailable" + }, + "progressbar.bar": { + "DataTransferBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", + "DefaultFdMixin": "class(fd=?, is_terminal=?, line_breaks=?, enable_colors=?, line_offset=?, **kwargs)", + "FrameType": "class()", + "NullBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", + "NumberT": "class(x=?)", + "ProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", + "ProgressBarBase": "class(**kwargs)", + "ProgressBarMixinBase": "class(**kwargs)", + "ResizableMixin": "class(term_width=?, **kwargs)", + "StdRedirectMixin": "class(redirect_stderr=?, redirect_stdout=?, redirect_blank_line=?, **kwargs)", + "T": "TypeVar", + "ValueT": "Union", + "annotations": "_Feature", + "datetime": "classsignature-unavailable", + "deepcopy": "callable(x, memo=?, _nil=?)", + "logger": "Logger" + }, + "progressbar.base": { + "FalseMeta": "classsignature-unavailable", + "IO": "class()", + "TextIO": "class()", + "Undefined": "class()", + "UnknownLength": "class()" + }, + "progressbar.env": { + "ANSI_TERMS": "tuple", + "ANSI_TERM_RE": "Pattern", + "COLOR_SUPPORT": "ColorSupport", + "ColorSupport": "class(*values)", + "JUPYTER": "bool", + "annotations": "_Feature", + "env_flag": "callable(name, default=?)", + "is_ansi_terminal": "callable(fd, is_terminal=?)", + "is_terminal": "callable(fd, is_terminal=?)" + }, + "progressbar.fast": { + "FastProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", + "annotations": "_Feature", + "datetime": "classsignature-unavailable", + "timedelta": "classsignature-unavailable" + }, + "progressbar.multi": { + "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, **progressbar_kwargs)", + "SortKey": "class(*values)", + "SortKeyFunc": "callable(*args, **kwargs)", + "annotations": "_Feature", + "timedelta": "classsignature-unavailable" + }, + "progressbar.shortcuts": { + "T": "TypeVar", + "annotations": "_Feature", + "progressbar": "callable(iterator, min_value=?, max_value=?, widgets=?, prefix=?, suffix=?, fast=?, **kwargs)" + }, + "progressbar.terminal": { + "CLEAR_LINE": "callable()", + "CLEAR_LINE_ALL": "callable(*args)", + "CLEAR_LINE_LEFT": "callable()", + "CLEAR_LINE_RIGHT": "callable()", + "CLEAR_SCREEN": "callable(*args)", + "CLEAR_SCREEN_ALL": "callable()", + "CLEAR_SCREEN_ALL_AND_HISTORY": "callable()", + "CLEAR_SCREEN_TILL_END": "callable()", + "CLEAR_SCREEN_TILL_START": "callable()", + "COLUMN": "callable(*args)", + "CSI": "class(code, *default_args)", + "CSINoArg": "class(code, *default_args)", + "CUP": "callable(*args)", + "ClassVar": "callable(*args, **kwds)", + "Color": "class(rgb, hls, name, xterm)", + "ColorBase": "class()", + "ColorGradient": "class(*colors, interpolate=?)", + "Colors": "class()", + "DOWN": "callable(*args)", + "DummyColor": "class()", + "ESC": "str", + "HIDE_CURSOR": "callable()", + "HSL": "class(hue, saturation, lightness)", + "Iterable": "class()", + "Iterator": "class()", + "LEFT": "callable(*args)", + "LastLineStream": "class(stream)", + "LineOffsetStreamWrapper": "class(lines=?, stream=?)", + "NEXT_LINE": "callable(*args)", + "OptionalColor": "Union", + "PREVIOUS_LINE": "callable(*args)", + "RESTORE_CURSOR": "callable()", + "RGB": "class(red, green, blue)", + "RIGHT": "callable(*args)", + "SAVE_CURSOR": "callable()", + "SCROLL_DOWN": "callable(*args)", + "SCROLL_UP": "callable(*args)", + "SGR": "class(start_code, end_code)", + "SGRColor": "class(color, start_code, end_code)", + "SHOW_CURSOR": "callable()", + "TextIOOutputWrapper": "class(stream)", + "TracebackType": "class(tb_next, tb_frame, tb_lasti, tb_lineno)", + "UP": "callable(*args)", + "WindowsColor": "class(color)", + "WindowsColors": "class(*values)", + "annotations": "_Feature", + "apply_colors": "callable(text, percentage=?, *, fg=?, bg=?, fg_none=?, bg_none=?, **kwargs)", + "bold": "callable(text, *args)", + "clear_line": "callable(n)", + "defaultdict": "classsignature-unavailable", + "double_underline": "callable(text, *args)", + "encircled": "callable(text, *args)", + "faint": "callable(text, *args)", + "fast_blink": "callable(text, *args)", + "framed": "callable(text, *args)", + "get_color": "callable(value, color)", + "getch": "callable()", + "gothic": "callable(text, *args)", + "inverse": "callable(text, *args)", + "italic": "callable(text, *args)", + "overline": "callable(text, *args)", + "slow_blink": "callable(text, *args)", + "strike_through": "callable(text, *args)", + "underline": "callable(text, *args)" + }, + "progressbar.terminal.base": { + "CLEAR_LINE": "callable()", + "CLEAR_LINE_ALL": "callable(*args)", + "CLEAR_LINE_LEFT": "callable()", + "CLEAR_LINE_RIGHT": "callable()", + "CLEAR_SCREEN": "callable(*args)", + "CLEAR_SCREEN_ALL": "callable()", + "CLEAR_SCREEN_ALL_AND_HISTORY": "callable()", + "CLEAR_SCREEN_TILL_END": "callable()", + "CLEAR_SCREEN_TILL_START": "callable()", + "COLUMN": "callable(*args)", + "CSI": "class(code, *default_args)", + "CSINoArg": "class(code, *default_args)", + "CUP": "callable(*args)", + "ClassVar": "callable(*args, **kwds)", + "Color": "class(rgb, hls, name, xterm)", + "ColorBase": "class()", + "ColorGradient": "class(*colors, interpolate=?)", + "Colors": "class()", + "DOWN": "callable(*args)", + "DummyColor": "class()", + "ESC": "str", + "HIDE_CURSOR": "callable()", + "HSL": "class(hue, saturation, lightness)", + "LEFT": "callable(*args)", + "NEXT_LINE": "callable(*args)", + "OptionalColor": "Union", + "PREVIOUS_LINE": "callable(*args)", + "RESTORE_CURSOR": "callable()", + "RGB": "class(red, green, blue)", + "RIGHT": "callable(*args)", + "SAVE_CURSOR": "callable()", + "SCROLL_DOWN": "callable(*args)", + "SCROLL_UP": "callable(*args)", + "SGR": "class(start_code, end_code)", + "SGRColor": "class(color, start_code, end_code)", + "SHOW_CURSOR": "callable()", + "UP": "callable(*args)", + "WindowsColor": "class(color)", + "WindowsColors": "class(*values)", + "annotations": "_Feature", + "apply_colors": "callable(text, percentage=?, *, fg=?, bg=?, fg_none=?, bg_none=?, **kwargs)", + "bold": "callable(text, *args)", + "clear_line": "callable(n)", + "defaultdict": "classsignature-unavailable", + "double_underline": "callable(text, *args)", + "encircled": "callable(text, *args)", + "faint": "callable(text, *args)", + "fast_blink": "callable(text, *args)", + "framed": "callable(text, *args)", + "get_color": "callable(value, color)", + "getch": "callable()", + "gothic": "callable(text, *args)", + "inverse": "callable(text, *args)", + "italic": "callable(text, *args)", + "overline": "callable(text, *args)", + "slow_blink": "callable(text, *args)", + "strike_through": "callable(text, *args)", + "underline": "callable(text, *args)" + }, + "progressbar.terminal.colors": { + "ColorGradient": "class(*colors, interpolate=?)", + "Colors": "class()", + "HSL": "class(hue, saturation, lightness)", + "RGB": "class(red, green, blue)", + "annotations": "_Feature", + "aqua": "callable(value)", + "aquamarine1": "callable(value)", + "aquamarine3": "callable(value)", + "bg_gradient": "callable(value)", + "black": "callable(value)", + "blue": "callable(value)", + "blue1": "callable(value)", + "blue3": "callable(value)", + "blue_violet": "callable(value)", + "cadet_blue": "callable(value)", + "chartreuse1": "callable(value)", + "chartreuse2": "callable(value)", + "chartreuse3": "callable(value)", + "chartreuse4": "callable(value)", + "cornflower_blue": "callable(value)", + "cornsilk1": "callable(value)", + "cyan1": "callable(value)", + "cyan2": "callable(value)", + "cyan3": "callable(value)", + "dark_blue": "callable(value)", + "dark_cyan": "callable(value)", + "dark_goldenrod": "callable(value)", + "dark_gradient": "callable(value)", + "dark_green": "callable(value)", + "dark_khaki": "callable(value)", + "dark_magenta": "callable(value)", + "dark_olive_green1": "callable(value)", + "dark_olive_green2": "callable(value)", + "dark_olive_green3": "callable(value)", + "dark_orange": "callable(value)", + "dark_orange3": "callable(value)", + "dark_red": "callable(value)", + "dark_sea_green": "callable(value)", + "dark_sea_green1": "callable(value)", + "dark_sea_green2": "callable(value)", + "dark_sea_green3": "callable(value)", + "dark_sea_green4": "callable(value)", + "dark_slate_gray1": "callable(value)", + "dark_slate_gray2": "callable(value)", + "dark_slate_gray3": "callable(value)", + "dark_turquoise": "callable(value)", + "dark_violet": "callable(value)", + "deep_pink1": "callable(value)", + "deep_pink2": "callable(value)", + "deep_pink3": "callable(value)", + "deep_pink4": "callable(value)", + "deep_sky_blue1": "callable(value)", + "deep_sky_blue2": "callable(value)", + "deep_sky_blue3": "callable(value)", + "deep_sky_blue4": "callable(value)", + "dodger_blue1": "callable(value)", + "dodger_blue2": "callable(value)", + "dodger_blue3": "callable(value)", + "fuchsia": "callable(value)", + "gold1": "callable(value)", + "gold3": "callable(value)", + "gradient": "callable(value)", + "green": "callable(value)", + "green1": "callable(value)", + "green3": "callable(value)", + "green4": "callable(value)", + "green_yellow": "callable(value)", + "grey": "callable(value)", + "grey0": "callable(value)", + "grey100": "callable(value)", + "grey11": "callable(value)", + "grey15": "callable(value)", + "grey19": "callable(value)", + "grey23": "callable(value)", + "grey27": "callable(value)", + "grey3": "callable(value)", + "grey30": "callable(value)", + "grey35": "callable(value)", + "grey37": "callable(value)", + "grey39": "callable(value)", + "grey42": "callable(value)", + "grey46": "callable(value)", + "grey50": "callable(value)", + "grey53": "callable(value)", + "grey54": "callable(value)", + "grey58": "callable(value)", + "grey62": "callable(value)", + "grey63": "callable(value)", + "grey66": "callable(value)", + "grey69": "callable(value)", + "grey7": "callable(value)", + "grey70": "callable(value)", + "grey74": "callable(value)", + "grey78": "callable(value)", + "grey82": "callable(value)", + "grey84": "callable(value)", + "grey85": "callable(value)", + "grey89": "callable(value)", + "grey93": "callable(value)", + "honeydew2": "callable(value)", + "hot_pink": "callable(value)", + "hot_pink2": "callable(value)", + "hot_pink3": "callable(value)", + "indian_red": "callable(value)", + "indian_red1": "callable(value)", + "khaki1": "callable(value)", + "khaki3": "callable(value)", + "light_coral": "callable(value)", + "light_cyan1": "callable(value)", + "light_cyan3": "callable(value)", + "light_goldenrod1": "callable(value)", + "light_goldenrod2": "callable(value)", + "light_goldenrod3": "callable(value)", + "light_gradient": "callable(value)", + "light_green": "callable(value)", + "light_pink1": "callable(value)", + "light_pink3": "callable(value)", + "light_pink4": "callable(value)", + "light_salmon1": "callable(value)", + "light_salmon3": "callable(value)", + "light_sea_green": "callable(value)", + "light_sky_blue1": "callable(value)", + "light_sky_blue3": "callable(value)", + "light_slate_blue": "callable(value)", + "light_slate_grey": "callable(value)", + "light_steel_blue": "callable(value)", + "light_steel_blue1": "callable(value)", + "light_steel_blue3": "callable(value)", + "light_yellow3": "callable(value)", + "lime": "callable(value)", + "magenta1": "callable(value)", + "magenta2": "callable(value)", + "magenta3": "callable(value)", + "maroon": "callable(value)", + "medium_orchid": "callable(value)", + "medium_orchid1": "callable(value)", + "medium_orchid3": "callable(value)", + "medium_purple": "callable(value)", + "medium_purple1": "callable(value)", + "medium_purple2": "callable(value)", + "medium_purple3": "callable(value)", + "medium_purple4": "callable(value)", + "medium_spring_green": "callable(value)", + "medium_turquoise": "callable(value)", + "medium_violet_red": "callable(value)", + "misty_rose1": "callable(value)", + "misty_rose3": "callable(value)", + "navajo_white1": "callable(value)", + "navajo_white3": "callable(value)", + "navy": "callable(value)", + "navy_blue": "callable(value)", + "olive": "callable(value)", + "orange1": "callable(value)", + "orange3": "callable(value)", + "orange4": "callable(value)", + "orange_red1": "callable(value)", + "orchid": "callable(value)", + "orchid1": "callable(value)", + "orchid2": "callable(value)", + "pale_green1": "callable(value)", + "pale_green3": "callable(value)", + "pale_turquoise1": "callable(value)", + "pale_turquoise4": "callable(value)", + "pale_violet_red1": "callable(value)", + "pink1": "callable(value)", + "pink3": "callable(value)", + "plum1": "callable(value)", + "plum2": "callable(value)", + "plum3": "callable(value)", + "plum4": "callable(value)", + "primary": "callable(value)", + "purple": "callable(value)", + "purple3": "callable(value)", + "purple4": "callable(value)", + "red": "callable(value)", + "red1": "callable(value)", + "red3": "callable(value)", + "rosy_brown": "callable(value)", + "royal_blue1": "callable(value)", + "salmon1": "callable(value)", + "sandy_brown": "callable(value)", + "sea_green1": "callable(value)", + "sea_green2": "callable(value)", + "sea_green3": "callable(value)", + "silver": "callable(value)", + "sky_blue1": "callable(value)", + "sky_blue2": "callable(value)", + "sky_blue3": "callable(value)", + "slate_blue1": "callable(value)", + "slate_blue3": "callable(value)", + "spring_green1": "callable(value)", + "spring_green2": "callable(value)", + "spring_green3": "callable(value)", + "spring_green4": "callable(value)", + "steel_blue": "callable(value)", + "steel_blue1": "callable(value)", + "steel_blue3": "callable(value)", + "tan": "callable(value)", + "teal": "callable(value)", + "thistle1": "callable(value)", + "thistle3": "callable(value)", + "turquoise2": "callable(value)", + "turquoise4": "callable(value)", + "violet": "callable(value)", + "wheat1": "callable(value)", + "wheat4": "callable(value)", + "white": "callable(value)", + "yellow": "callable(value)", + "yellow1": "callable(value)", + "yellow2": "callable(value)", + "yellow3": "callable(value)", + "yellow4": "callable(value)" + }, + "progressbar.terminal.stream": { + "Iterable": "class()", + "Iterator": "class()", + "LastLineStream": "class(stream)", + "LineOffsetStreamWrapper": "class(lines=?, stream=?)", + "TextIOOutputWrapper": "class(stream)", + "TracebackType": "class(tb_next, tb_frame, tb_lasti, tb_lineno)", + "annotations": "_Feature" + }, + "progressbar.utils": { + "@py_assert1": "NoneType", + "@py_assert2": "NoneType", + "AttributeDict": "classsignature-unavailable", + "Iterable": "class()", + "Iterator": "class()", + "StreamWrapper": "class()", + "StringT": "TypeVar", + "TracebackType": "class(tb_next, tb_frame, tb_lasti, tb_lineno)", + "WrappingIO": "class(target, capturing=?, listeners=?)", + "annotations": "_Feature", + "deltas_to_seconds": "callable(*deltas, default=?)", + "epoch": "datetime", + "format_time": "callable(timestamp, precision=?)", + "get_terminal_size": "callable()", + "len_color": "callable(value)", + "logger": "Logger", + "no_color": "callable(value)", + "scale_1024": "callable(x, n_prefixes)", + "streams": "StreamWrapper", + "timedelta_to_seconds": "callable(delta)" + }, + "progressbar.widgets": { + "AbsoluteETA": "class(format_not_started=?, format_finished=?, format=?, **kwargs)", + "AdaptiveETA": "class(exponential_smoothing=?, exponential_smoothing_factor=?, **kwargs)", + "AdaptiveTransferSpeed": "class(**kwargs)", + "AnimatedMarker": "class(markers=?, default=?, fill=?, marker_wrap=?, fill_wrap=?, **kwargs)", + "AutoWidthWidgetBase": "class(*args, fixed_colors=?, gradient_colors=?, **kwargs)", + "Bar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", + "BouncingBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", + "ClassVar": "callable(*args, **kwds)", + "ColoredMixin": "class()", + "Counter": "class(format=?, **kwargs)", + "CurrentTime": "class(format=?, microseconds=?, **kwargs)", + "Data": "callable(*args, **kwargs)", + "DataSize": "class(variable=?, format=?, unit=?, prefixes=?, **kwargs)", + "DynamicMessage": "class(name, format=?, width=?, precision=?, **kwargs)", + "ETA": "class(format_not_started=?, format_finished=?, format=?, format_zero=?, format_na=?, **kwargs)", + "FileTransferSpeed": "class(format=?, inverse_format=?, unit=?, prefixes=?, **kwargs)", + "FormatCustomText": "class(format, mapping=?, **kwargs)", + "FormatLabel": "class(format, **kwargs)", + "FormatLabelBar": "class(format, **kwargs)", + "FormatString": "Union", + "FormatWidgetMixin": "class(format, new_style=?, **kwargs)", + "GranularBar": "class(markers=?, left=?, right=?, **kwargs)", + "GranularMarkers": "class()", + "JobStatusBar": "class(name, left=?, right=?, fill=?, fill_left=?, success_fg_color=?, success_bg_color=?, success_marker=?, failure_fg_color=?, failure_bg_color=?, failure_marker=?, **kwargs)", + "MAX_DATE": "date", + "MAX_DATETIME": "datetime", + "MAX_TIME": "time", + "MultiProgressBar": "class(name, markers=?, **kwargs)", + "MultiRangeBar": "class(name, markers, **kwargs)", + "Percentage": "class(format=?, na=?, **kwargs)", + "PercentageLabelBar": "class(format=?, na=?, **kwargs)", + "ReverseBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, **kwargs)", + "RotatingMarker": "class(markers=?, default=?, fill=?, marker_wrap=?, fill_wrap=?, **kwargs)", + "SamplesMixin": "class(samples=?, key_prefix=?, **kwargs)", + "SimpleProgress": "class(format=?, **kwargs)", + "SmoothingETA": "class(smoothing_algorithm=?, smoothing_parameters=?, **kwargs)", + "T": "TypeVar", + "TFixedColors": "classsignature-unavailable", + "TGradientColors": "classsignature-unavailable", + "TimeSensitiveWidgetBase": "class(*args, fixed_colors=?, gradient_colors=?, **kwargs)", + "Timer": "class(format=?, **kwargs)", + "Variable": "class(name, format=?, width=?, precision=?, **kwargs)", + "VariableMixin": "class(name, **kwargs)", + "WidgetBase": "class(*args, fixed_colors=?, gradient_colors=?, **kwargs)", + "WidthWidgetMixin": "class(min_width=?, max_width=?, **kwargs)", + "annotations": "_Feature", + "create_marker": "callable(marker, wrap=?)", + "create_wrapper": "callable(wrapper)", + "logger": "Logger", + "string_or_lambda": "callable(input_)", + "wrapper": "callable(function, wrapper_)" + } +} diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py new file mode 100644 index 00000000..95a70b70 --- /dev/null +++ b/tests/test_api_surface.py @@ -0,0 +1,148 @@ +"""Public API surface snapshot. + +Guards the backwards-compatibility contract while the quality-audit +refactors land: every public module keeps its public names, and every +public callable keeps its parameter names and kinds. + +The snapshot deliberately records only parameter *names* and *kinds* +(positional / keyword / var-positional / var-keyword) plus whether a +default exists. Annotations and default-value reprs are excluded so a +single snapshot is stable across Python 3.10-3.15 and so widening a type +annotation does not require a snapshot update. Removing or renaming a +parameter, changing its kind, or dropping a public name fails the test. + +Regenerate after a deliberate, reviewed API addition with: + + PROGRESSBAR_UPDATE_API_SNAPSHOT=1 pytest tests/test_api_surface.py +""" + +from __future__ import annotations + +import importlib +import inspect +import json +import os +import pathlib +import types +import typing + +import pytest + +SNAPSHOT_PATH: pathlib.Path = ( + pathlib.Path(__file__).parent / 'api_surface_snapshot.json' +) + +#: Modules whose public surface is under the compatibility contract. +PUBLIC_MODULES: tuple[str, ...] = ( + 'progressbar', + 'progressbar.algorithms', + 'progressbar.bar', + 'progressbar.base', + 'progressbar.env', + 'progressbar.fast', + 'progressbar.multi', + 'progressbar.shortcuts', + 'progressbar.terminal', + 'progressbar.terminal.base', + 'progressbar.terminal.colors', + 'progressbar.terminal.stream', + 'progressbar.utils', + 'progressbar.widgets', +) + + +def _describe_signature(obj: typing.Any) -> str: + """Return a version-stable signature descriptor for a callable.""" + try: + signature = inspect.signature(obj) + except (ValueError, TypeError): + return 'signature-unavailable' + + parts: list[str] = [] + for name, parameter in signature.parameters.items(): + prefix = { + inspect.Parameter.VAR_POSITIONAL: '*', + inspect.Parameter.VAR_KEYWORD: '**', + }.get(parameter.kind, '') + suffix = '=?' if parameter.default is not parameter.empty else '' + parts.append(f'{prefix}{name}{suffix}') + + if parameter.kind is inspect.Parameter.KEYWORD_ONLY and ( + '*' not in ''.join(parts[:-1]) + ): + # Mark the keyword-only boundary once so converting a + # positional parameter to keyword-only is visible. + parts.insert(len(parts) - 1, '*') + + return f'({", ".join(parts)})' + + +def _describe(obj: typing.Any) -> str: + if inspect.isclass(obj): + return f'class{_describe_signature(obj)}' + if callable(obj): + return f'callable{_describe_signature(obj)}' + return type(obj).__name__ + + +def _public_names(module: types.ModuleType) -> list[str]: + explicit = getattr(module, '__all__', None) + if explicit is not None: + return sorted(explicit) + return sorted( + name + for name in dir(module) + if not name.startswith('_') + and not isinstance(getattr(module, name), types.ModuleType) + ) + + +def build_surface() -> dict[str, dict[str, str]]: + surface: dict[str, dict[str, str]] = {} + for module_name in PUBLIC_MODULES: + module = importlib.import_module(module_name) + surface[module_name] = { + name: _describe(getattr(module, name)) + for name in _public_names(module) + } + return surface + + +@pytest.mark.no_freezegun +def test_api_surface_snapshot() -> None: + # no_freezegun: the surface describes module constants by type name; + # freezegun would report FakeDate/FakeDatetime for MAX_DATE/MAX_DATETIME. + surface: dict[str, dict[str, str]] = build_surface() + + if os.environ.get('PROGRESSBAR_UPDATE_API_SNAPSHOT'): + SNAPSHOT_PATH.write_text( + json.dumps(surface, indent=2, sort_keys=True) + '\n', + ) + pytest.skip('API surface snapshot regenerated') + + assert SNAPSHOT_PATH.exists(), ( + 'Missing API snapshot; generate it with ' + 'PROGRESSBAR_UPDATE_API_SNAPSHOT=1 pytest tests/test_api_surface.py' + ) + snapshot: dict[str, dict[str, str]] = json.loads( + SNAPSHOT_PATH.read_text(), + ) + + problems: list[str] = [] + for module_name, expected in snapshot.items(): + current = surface.get(module_name) + if current is None: + problems.append(f'module removed: {module_name}') + continue + for name, descriptor in expected.items(): + if name not in current: + problems.append(f'{module_name}.{name}: removed') + elif current[name] != descriptor: + problems.append( + f'{module_name}.{name}: {descriptor} -> {current[name]}', + ) + + assert not problems, ( + 'Public API changed; if the change is a deliberate, reviewed ' + 'widening, regenerate the snapshot:\n' + '\n'.join(problems) + ) diff --git a/tests/test_subclass_compat.py b/tests/test_subclass_compat.py new file mode 100644 index 00000000..bfcfc919 --- /dev/null +++ b/tests/test_subclass_compat.py @@ -0,0 +1,309 @@ +"""Third-party subclass compatibility characterization tests. + +progressbar2 is subclassed in the wild in two styles: + +1. *Old style* — explicit unbound parent ``__init__`` calls, copying the + library's own historic pattern + (``FormatWidgetMixin.__init__(self, ...)`` followed by + ``WidgetBase.__init__(self, ...)``). +2. *Super style* — a single cooperative ``super().__init__(...)``. + +Both styles must construct and render identically before and after the +cooperative-``super()`` migration; these tests characterize today's +behavior and act as the gate for that refactor. The golden-render tests +additionally pin the exact default rendering so any refactor that +changes output byte-for-byte fails loudly. +""" + +from __future__ import annotations + +import io +import typing + +import pytest + +import progressbar +from progressbar import widgets + + +def _render( + widget_list: list[typing.Any], + max_value: int = 10, + term_width: int = 60, +) -> str: + fd = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd, + max_value=max_value, + widgets=widget_list, + term_width=term_width, + ) + bar.start() + bar.update(5, force=True) + bar.finish() + return fd.getvalue() + + +# --- (a) old style: explicit unbound parent __init__ calls ----------------- + + +class OldStyleWidget(widgets.FormatWidgetMixin, widgets.WidgetBase): + def __init__( + self, + my_param: str = 'x', + format: str = '%(value)d!', + **kwargs: typing.Any, + ): + self.my_param = my_param + widgets.FormatWidgetMixin.__init__(self, format=format, **kwargs) + widgets.WidgetBase.__init__(self, **kwargs) + + def __call__(self, progress, data, format=None): + return widgets.FormatWidgetMixin.__call__(self, progress, data) + + +class OldStyleCounterClone(widgets.FormatWidgetMixin, widgets.WidgetBase): + """Copies the library's historic ``format=``-leak style verbatim.""" + + def __init__(self, format: str = '%(value)d', **kwargs: typing.Any): + widgets.FormatWidgetMixin.__init__(self, format=format, **kwargs) + widgets.WidgetBase.__init__(self, format=format, **kwargs) + + def __call__(self, progress, data, format=None): + return widgets.FormatWidgetMixin.__call__(self, progress, data) + + +class OldStyleSamplesWidget(widgets.SamplesMixin): + def __init__(self, **kwargs: typing.Any): + # samples accepts int per the class docstring/doctest; the + # timedelta-only annotation is a known typing gap (PR 4). + widgets.SamplesMixin.__init__( + self, + samples=3, # pyright: ignore[reportArgumentType] + **kwargs, + ) + + def __call__(self, progress, data): # pragma: no cover - never rendered + return str(widgets.SamplesMixin.__call__(self, progress, data)) + + +def test_old_style_widget_constructs_and_renders() -> None: + widget = OldStyleWidget(min_width=1, max_width=100) + # kwargs must keep reaching WidthWidgetMixin through both parent calls + assert widget.min_width == 1 + assert widget.max_width == 100 + assert widget.format == '%(value)d!' + assert '5!' in _render([widget]) + + +def test_old_style_format_leak_still_constructs() -> None: + # Must never raise, before or after the super() migration. + widget = OldStyleCounterClone(min_width=2) + assert widget.min_width == 2 + assert '5' in _render([widget]) + + +def test_old_style_samples_mixin() -> None: + widget = OldStyleSamplesWidget() + assert widget.samples == 3 + assert widget.key_prefix == 'OldStyleSamplesWidget_' + + +# --- (b) super style: single cooperative call ------------------------------- + + +class SuperStyleWidget(widgets.FormatWidgetMixin, widgets.WidgetBase): + def __init__( + self, + my_param: str = 'x', + format: str = '%(value)d?', + **kwargs: typing.Any, + ): + self.my_param = my_param + super().__init__(format=format, **kwargs) + + def __call__(self, progress, data, format=None): + return super().__call__(progress, data) + + +def test_super_style_widget_sets_format() -> None: + # FormatWidgetMixin is first in the MRO, so this works even today. + widget = SuperStyleWidget() + 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 + assert widget.format == '%(value)d?' + assert '5?' in _render([widget]) + + +def test_super_style_diamond_subclass() -> None: + class MyETA(widgets.AdaptiveETA): + def __init__(self, **kwargs: typing.Any): + super().__init__(samples=7, **kwargs) + + widget = MyETA() + assert widget.samples == 7 + _render([widget]) + + +# --- library widgets: constructor kwargs must land where they belong ------- + + +@pytest.mark.parametrize( + 'widget_class, args', + [ + (widgets.FormatLabel, ('%(value)s',)), + (widgets.Timer, ()), + (widgets.ETA, ()), + (widgets.AdaptiveETA, ()), + (widgets.Counter, ()), + (widgets.Percentage, ()), + (widgets.SimpleProgress, ()), + (widgets.PercentageLabelBar, ()), + (widgets.FormatLabelBar, ('%(value)s',)), + ], +) +def test_width_kwargs_reach_width_mixin(widget_class, args) -> None: + widget = widget_class(*args, min_width=3, max_width=90) + assert widget.min_width == 3 + assert widget.max_width == 90 + + +def test_variable_name_reaches_variable_mixin() -> None: + assert widgets.MultiRangeBar('jobs', markers=[' ', '#']).name == 'jobs' + assert widgets.JobStatusBar('status').name == 'status' + assert widgets.Variable('speed', precision=2).name == 'speed' + + +# --- ProgressBar subclasses, both styles ------------------------------------ + + +class OldStyleBar(progressbar.ProgressBar): + """Copies the library's current explicit-parent-call style.""" + + def __init__(self, *args: typing.Any, **kwargs: typing.Any): + progressbar.ProgressBar.__init__(self, *args, **kwargs) + self.custom = True + + +class SuperStyleBar(progressbar.ProgressBar): + def __init__( + self, + *args: typing.Any, + my_option: str | None = None, + **kwargs: typing.Any, + ): + self.my_option = my_option + super().__init__(*args, **kwargs) + + def start(self, *args: typing.Any, **kwargs: typing.Any): + self.start_hook = True + return super().start(*args, **kwargs) + + def update(self, value=None, force=False, **kwargs: typing.Any): + self.update_hook = getattr(self, 'update_hook', 0) + 1 + super().update(value, force=force, **kwargs) + + +@pytest.mark.parametrize( + 'bar_class', + [OldStyleBar, SuperStyleBar, progressbar.ProgressBar], +) +def test_bar_subclass_lifecycle(bar_class) -> None: + fd = io.StringIO() + with bar_class(fd=fd, max_value=5, term_width=60) as bar: + for i in range(5): + bar.update(i + 1, force=True) + assert bar.finished() + assert '100%' in fd.getvalue() + + +def test_index_consumed_once_per_bar() -> None: + first = progressbar.ProgressBar( + fd=io.StringIO(), max_value=1, term_width=60 + ) + second = OldStyleBar(fd=io.StringIO(), max_value=1, term_width=60) + # Bar indexes must stay monotonic, exactly one per instance; the + # cooperative migration must not make subclasses consume extra ones. + assert second.index == first.index + 1 + + +# --- golden rendering -------------------------------------------------------- + + +def test_default_widgets_render_identically() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd, + min_value=0, + max_value=10, + term_width=80, + enable_colors=False, + line_breaks=True, + ) + bar.start() + for i in (3, 7, 10): + bar.update(i, force=True) + bar.finish() + out = fd.getvalue() + assert '100%' in out + assert '10 of 10' in out + assert 'Elapsed Time' in out + + +def test_known_length_render_golden() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd, + max_value=10, + term_width=40, + enable_colors=False, + widgets=[ + widgets.Percentage(), + ' ', + widgets.SimpleProgress(), + ' ', + widgets.Bar(), + ], + ) + bar.start() + bar.update(5, force=True) + bar.finish() + assert _final_line(fd) == GOLDEN_KNOWN_LENGTH_FINAL + + +def test_unknown_length_render_golden() -> None: + fd = io.StringIO() + bar = progressbar.ProgressBar( + fd=fd, + max_value=progressbar.UnknownLength, + term_width=40, + enable_colors=False, + widgets=[widgets.Counter(), ' ', widgets.Timer()], + ) + bar.start() + bar.update(5, force=True) + bar.finish() + assert _final_line(fd) == GOLDEN_UNKNOWN_LENGTH_FINAL + + +def _final_line(fd: io.StringIO) -> str: + lines = [ + line.strip('\r') for line in fd.getvalue().splitlines() if line.strip() + ] + return lines[-1] + + +# Exact expected final lines, captured from the current release behavior +# 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' From ee14042480b7a4f707d4311fe03fa1ffe0c4b014 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 16:45:23 +0200 Subject: [PATCH 02/24] fix(widgets): copy-on-write per-instance color overrides Passing fixed_colors/gradient_colors to a single widget instance called dict.update() on the shared class-level mapping, rewriting colors for every other instance and subclass. Merge overrides into a fresh per-instance dict instead, leaving the class default untouched. The two mappings are no longer ClassVar since instances may now legitimately hold their own copy. --- progressbar/widgets.py | 23 +++++++++++++++------ tests/test_color.py | 46 +++++++++++++++++++++++++++++++++++------- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 4b9b6c5a..d14f8140 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -258,11 +258,14 @@ def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: progress - a reference to the calling ProgressBar """ - _fixed_colors: ClassVar[TFixedColors] = TFixedColors( + # Class-level defaults; instances may hold their own copy when a + # ``fixed_colors``/``gradient_colors`` override is passed (copy-on-write in + # ``__init__``), so these are not ``ClassVar``. + _fixed_colors: TFixedColors = TFixedColors( fg_none=None, bg_none=None, ) - _gradient_colors: ClassVar[TGradientColors] = TGradientColors( + _gradient_colors: TGradientColors = TGradientColors( fg=None, bg=None, ) @@ -297,11 +300,18 @@ def __init__( gradient_colors=None, **kwargs, ): + # Copy-on-write: merge overrides into a fresh per-instance dict so we + # never mutate the shared class-level mapping (which is inherited by + # every other instance and subclass). if fixed_colors is not None: - self._fixed_colors.update(fixed_colors) + merged_fixed = type(self)._fixed_colors.copy() + merged_fixed.update(fixed_colors) + self._fixed_colors = merged_fixed if gradient_colors is not None: - self._gradient_colors.update(gradient_colors) + merged_gradient = type(self)._gradient_colors.copy() + merged_gradient.update(gradient_colors) + self._gradient_colors = merged_gradient if self.uses_colors: self._len = utils.len_color @@ -913,11 +923,12 @@ def __call__( class ColoredMixin: - _fixed_colors: ClassVar[TFixedColors] = TFixedColors( + # See ``WidgetBase``: class-level defaults, overridable per instance. + _fixed_colors: TFixedColors = TFixedColors( fg_none=colors.yellow, bg_none=None, ) - _gradient_colors: ClassVar[TGradientColors] = TGradientColors( + _gradient_colors: TGradientColors = TGradientColors( fg=colors.gradient, bg=None, ) diff --git a/tests/test_color.py b/tests/test_color.py index 3c0f5fb4..e827a932 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -from typing import ClassVar import pytest @@ -133,7 +132,7 @@ def test_enable_colors_flags() -> None: class _TestFixedColorSupport(progressbar.widgets.WidgetBase): - _fixed_colors: ClassVar[widgets.TFixedColors] = widgets.TFixedColors( + _fixed_colors: widgets.TFixedColors = widgets.TFixedColors( fg_none=progressbar.widgets.colors.yellow, bg_none=None, ) @@ -143,11 +142,9 @@ def __call__(self, *args, **kwargs) -> None: class _TestFixedGradientSupport(progressbar.widgets.WidgetBase): - _gradient_colors: ClassVar[widgets.TGradientColors] = ( - widgets.TGradientColors( - fg=progressbar.widgets.colors.gradient, - bg=None, - ) + _gradient_colors: widgets.TGradientColors = widgets.TGradientColors( + fg=progressbar.widgets.colors.gradient, + bg=None, ) def __call__(self, *args, **kwargs) -> None: @@ -430,3 +427,38 @@ def test_color_support_force_color_flag(monkeypatch, value) -> None: monkeypatch.setenv('FORCE_COLOR', value) assert env.ColorSupport.from_env() == env.ColorSupport.XTERM_TRUECOLOR + + +class _PerInstanceColorWidget(progressbar.widgets.WidgetBase): + def __call__(self, *args, **kwargs) -> None: # pragma: no cover + pass + + +def test_fixed_colors_override_is_per_instance() -> None: + # Regression: F1 - passing ``fixed_colors`` to one instance mutated the + # shared class-level dict, rewriting colors for every other instance and + # subclass. The override must be copy-on-write. + class_default = dict(_PerInstanceColorWidget._fixed_colors) + override = widgets.TFixedColors(fg_none=colors.yellow, bg_none=None) + + a = _PerInstanceColorWidget(fixed_colors=override) + b = _PerInstanceColorWidget() + + assert _PerInstanceColorWidget._fixed_colors == class_default + assert a._fixed_colors['fg_none'] == colors.yellow + assert b._fixed_colors == class_default + assert a._fixed_colors is not override + + +def test_gradient_colors_override_is_per_instance() -> None: + # Regression: F1 - same copy-on-write requirement for ``gradient_colors``. + class_default = dict(_PerInstanceColorWidget._gradient_colors) + override = widgets.TGradientColors(fg=colors.gradient, bg=None) + + a = _PerInstanceColorWidget(gradient_colors=override) + b = _PerInstanceColorWidget() + + assert _PerInstanceColorWidget._gradient_colors == class_default + assert a._gradient_colors['fg'] == colors.gradient + assert b._gradient_colors == class_default + assert a._gradient_colors is not override From fb5ff9e900e560ba7344f23321f2563d13a26ea2 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 16:46:58 +0200 Subject: [PATCH 03/24] fix(widgets): give FormatCustomText a per-instance mapping The class-level `mapping = dict()` default was aliased by every default-constructed FormatCustomText, so update_mapping() on one instance mutated all others. Build a fresh dict from the argument instead, and fix the update_mapping() value annotation (was Dict, values are arbitrary). --- progressbar/widgets.py | 7 +++++-- tests/test_custom_widgets.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index d14f8140..9dc29643 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1185,11 +1185,14 @@ def __init__( **kwargs, ): self.format = format - self.mapping = mapping or self.mapping + # Always build a fresh per-instance dict so update_mapping() never + # mutates the shared class-level default (which every other + # default-constructed instance would otherwise alias). + self.mapping = dict(mapping or {}) FormatWidgetMixin.__init__(self, format=format, **kwargs) WidgetBase.__init__(self, **kwargs) - def update_mapping(self, **mapping: types.Dict[str, types.Any]): + def update_mapping(self, **mapping: types.Any): self.mapping.update(mapping) def __call__( diff --git a/tests/test_custom_widgets.py b/tests/test_custom_widgets.py index b0a272e4..23fb3945 100644 --- a/tests/test_custom_widgets.py +++ b/tests/test_custom_widgets.py @@ -93,3 +93,20 @@ def test_format_custom_text_widget() -> None: for i in bar(range(5)): widget.update_mapping(eggs=i * 2) assert widget.mapping['eggs'] == bar.widgets[0].mapping['eggs'] + + +def test_format_custom_text_mapping_is_per_instance() -> None: + # Regression: F2 - default-constructed FormatCustomText instances shared + # the mutable class-level ``mapping`` dict, so update_mapping on one bled + # into every other instance (and the class attribute). + class_default = dict(progressbar.FormatCustomText.mapping) + + a = progressbar.FormatCustomText('%(spam)s') + b = progressbar.FormatCustomText('%(spam)s') + + a.update_mapping(spam='eggs') + + assert a.mapping == {'spam': 'eggs'} + assert b.mapping == {} + assert a.mapping is not b.mapping + assert progressbar.FormatCustomText.mapping == class_default From 7c3319e841b65681d06bc280102a326a217abc83 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 16:51:43 +0200 Subject: [PATCH 04/24] fix(widgets): keep JobStatusBar marker state per-bar in progress.extra A single JobStatusBar reused across two ProgressBars accumulated its marker history on the widget instance, interleaving the two bars' output. Store the marker list in progress.extra under a per-widget key (mirroring SamplesMixin), so the widget stays stateless and reusable. The job_markers attribute is retained for backward compatibility but no longer holds render state. --- progressbar/widgets.py | 22 +++++++++++++++++----- tests/test_job_status.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 9dc29643..68049cae 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1598,7 +1598,13 @@ def __init__( ): 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 + # multiple bars no longer interleaves their markers. self.job_markers = [] + # Unique per-widget key so multiple JobStatusBars on the same bar do + # not share storage either. + self._markers_key = f'{type(self).__name__}_{id(self)}_job_markers' self.left = string_or_lambda(left) self.right = string_or_lambda(right) self.fill = string_or_lambda(fill) @@ -1618,6 +1624,11 @@ def __init__( **kwargs, ) + def get_job_markers(self, progress: ProgressBarMixinBase) -> list[str]: + # Per-bar marker history, following SamplesMixin's ``progress.extra`` + # pattern so the widget itself stays stateless and reusable. + return progress.extra.setdefault(self._markers_key, []) + def __call__( self, progress: ProgressBarMixinBase, @@ -1650,16 +1661,17 @@ def __call__( if bg_color: # pragma: no cover marker = bg_color.bg(marker) - self.job_markers.append(marker) + job_markers = self.get_job_markers(progress) + job_markers.append(marker) # Drop the oldest markers when they no longer fit the # available width while ( - len(self.job_markers) > 1 - and progress.custom_len(''.join(self.job_markers)) > width + len(job_markers) > 1 + and progress.custom_len(''.join(job_markers)) > width ): - self.job_markers.pop(0) + job_markers.pop(0) - marker = ''.join(self.job_markers) + marker = ''.join(job_markers) width -= progress.custom_len(marker) fill = converters.to_unicode(self.fill(progress, data, width)) diff --git a/tests/test_job_status.py b/tests/test_job_status.py index d4770908..9747feac 100644 --- a/tests/test_job_status.py +++ b/tests/test_job_status.py @@ -46,3 +46,42 @@ def test_job_status_bar_does_not_overflow_width() -> None: assert utils.len_color(output) <= width bar.finish(dirty=True) + + +def _make_status_bar() -> tuple: + bar = progressbar.ProgressBar( + widgets=[progressbar.widgets.JobStatusBar('status')], + variables={'status': None}, + max_value=100, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + data = bar.data() + data['variables'] = {'status': True} + return bar, data + + +def test_job_markers_do_not_interleave_across_bars() -> None: + # Regression: F2 - a single JobStatusBar reused by two ProgressBars kept + # its marker history on the widget itself, so markers from one bar bled + # into the other. State must live in ``progress.extra`` per bar. + widget = progressbar.widgets.JobStatusBar('status') + + bar_a, data_a = _make_status_bar() + bar_b, data_b = _make_status_bar() + + width = 20 + for _ in range(3): + widget(bar_a, data_a, width=width) + widget(bar_b, data_b, width=width) + + markers_a = widget.get_job_markers(bar_a) + markers_b = widget.get_job_markers(bar_b) + + assert len(markers_a) == 3 + assert len(markers_b) == 1 + assert markers_a is not markers_b + + bar_a.finish(dirty=True) + bar_b.finish(dirty=True) From af44a00524252701f98b8e9836e6d4deea200bc3 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 16:53:20 +0200 Subject: [PATCH 05/24] fix(widgets): validate markers/wrappers with ValueError not assert create_wrapper, create_marker and MultiRangeBar's render path used bare `assert` for user-facing validation, which is stripped under `python -O`, turning invalid input into silent corruption. Raise ValueError with the same messages instead. Existing AssertionError expectation updated. --- progressbar/widgets.py | 11 ++++++----- tests/test_widgets.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 68049cae..266d89a8 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -60,7 +60,8 @@ def create_wrapper(wrapper): return None if isinstance(wrapper, str): - assert '{}' in wrapper, 'Expected string with {} for formatting' + if '{}' not in wrapper: + raise ValueError('Expected string with {} for formatting') else: raise RuntimeError( # noqa: TRY004 'Pass either a begin/end string as a tuple or a template string ' @@ -110,9 +111,8 @@ def _marker(progress, data, width): if isinstance(marker, str): marker = converters.to_unicode(marker) - # Ruff is silly at times... the format is not compatible with the check - marker_length_error = 'Markers are required to be 1 char' - assert utils.len_color(marker) == 1, marker_length_error + if utils.len_color(marker) != 1: + raise ValueError('Markers are required to be 1 char') return wrapper(_marker, wrap) else: return wrapper(marker, wrap) @@ -1259,7 +1259,8 @@ def __call__( width_accumulated = 0 for marker, value in zip(self.markers, values): marker = converters.to_unicode(marker(progress, data, width)) - assert progress.custom_len(marker) == 1 + if progress.custom_len(marker) != 1: + raise ValueError('Markers are required to be 1 char') values_accumulated += value item_width = int(values_accumulated / values_sum * width) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index af4f1a33..6d6314e7 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -16,13 +16,40 @@ def test_create_wrapper() -> None: - with pytest.raises(AssertionError): + # F4: user-facing validation must raise ValueError (not a bare assert that + # vanishes under ``python -O``). + with pytest.raises(ValueError): progressbar.widgets.create_wrapper('ab') with pytest.raises(RuntimeError): progressbar.widgets.create_wrapper(123) +def test_create_marker_rejects_multichar_marker() -> None: + # F4: markers must be a single visible character. + with pytest.raises(ValueError): + progressbar.widgets.create_marker('ab') + + +def test_multi_range_bar_rejects_multichar_marker() -> None: + # F4: the render path validates marker width; a 2-char marker must raise + # ValueError rather than a stripped-under-O assert. + widget = progressbar.MultiRangeBar('amounts', markers=['ab', ' ']) + bar = progressbar.ProgressBar( + widgets=[widget], + variables={'amounts': []}, + max_value=10, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + data = bar.data() + data['variables'] = {'amounts': [1, 0]} + with pytest.raises(ValueError): + widget(bar, data, width=20) + bar.finish(dirty=True) + + def test_widgets_small_values() -> None: widgets = [ 'Test: ', From 7eb2297adec5f65c95ca1cf1ec001b3562b18c66 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 16:57:09 +0200 Subject: [PATCH 06/24] perf(utils): precompile ANSI escape patterns in no_color The bytes CSI pattern was rebuilt on every no_color() call and the str branch relied on re's internal cache; both run per widget per redraw. Compile both variants once at module import and reuse via Pattern.sub(). --- progressbar/utils.py | 13 ++++++++++--- tests/test_fastpath.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/progressbar/utils.py b/progressbar/utils.py index 01885dda..3cfc14f9 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -30,6 +30,14 @@ StringT = types.TypeVar('StringT', bound=types.StringTypes) +# Precompiled ANSI CSI escape-sequence patterns (str and bytes). Compiled once +# at import instead of per no_color() call, which runs for every widget on +# every redraw. +_ANSI_COLOR_RE: re.Pattern[str] = re.compile('\x1b\\[.*?[@-~]') +_ANSI_COLOR_RE_BYTES: re.Pattern[bytes] = re.compile( + bytes(terminal.ESC, 'ascii') + b'\\[.*?[@-~]', +) + def deltas_to_seconds( *deltas: None | datetime.timedelta | float, @@ -98,12 +106,11 @@ def no_color(value: StringT) -> StringT: # per-redraw render cost (len_color is called for every widget). if b'\x1b' not in value: return value # type: ignore - pattern: bytes = bytes(terminal.ESC, 'ascii') + b'\\[.*?[@-~]' - return re.sub(pattern, b'', value) # type: ignore + return _ANSI_COLOR_RE_BYTES.sub(b'', value) # type: ignore elif isinstance(value, str): if '\x1b' not in value: return value # type: ignore - return re.sub('\x1b\\[.*?[@-~]', '', value) # type: ignore + return _ANSI_COLOR_RE.sub('', value) # type: ignore else: raise TypeError(f'`value` must be a string or bytes, got {value!r}') diff --git a/tests/test_fastpath.py b/tests/test_fastpath.py index 50d1b2c2..eedda601 100644 --- a/tests/test_fastpath.py +++ b/tests/test_fastpath.py @@ -593,6 +593,17 @@ def test_no_color_fast_path_and_ansi(): assert utils.len_color('\x1b[1mbold\x1b[0m') == 4 +def test_no_color_patterns_are_precompiled(): + # F5: the str and bytes ANSI patterns are compiled once at module import, + # not rebuilt on every no_color() call (which runs per widget per redraw). + utils = progressbar.utils + assert isinstance(utils._ANSI_COLOR_RE, re.Pattern) + assert isinstance(utils._ANSI_COLOR_RE_BYTES, re.Pattern) + # Both variants must still strip ANSI correctly. + assert utils.no_color('\x1b[32mgreen\x1b[0m') == 'green' + assert utils.no_color(b'\x1b[32mgreen\x1b[0m') == b'green' + + def test_render_output_stable(monkeypatch): # Guard the default-widget render path against the render-cost # optimization changing appearance: the final repaint must reach 100%. From 55e52a8f185ee1506d1196b2ccc759d12ae093bf Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 17:15:26 +0200 Subject: [PATCH 07/24] fix(terminal): threshold RGB.to_ansi_16 channels at half intensity int(c / 255) was only 1 at exactly 255, collapsing every mid-intensity colour (e.g. maroon 128,0,0) to black. Threshold each channel at 128 so it sets its own bit. --- progressbar/terminal/base.py | 10 ++++++---- tests/test_color.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index 2d299f4e..53e2d315 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -280,10 +280,12 @@ def hex(self) -> str: @property def to_ansi_16(self) -> int: - # Using int instead of round because it maps slightly better - red = int(self.red / 255) - green = int(self.green / 255) - blue = int(self.blue / 255) + # Threshold each channel at half intensity so a mid-range channel + # sets its bit. ``int(c / 255)`` was only ever 1 at exactly 255, which + # collapsed almost every colour (e.g. maroon 128,0,0) to black. + red = int(self.red >= 128) + green = int(self.green >= 128) + blue = int(self.blue >= 128) return (blue << 2) | (green << 1) | red @property diff --git a/tests/test_color.py b/tests/test_color.py index e827a932..1ba2d287 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -273,6 +273,26 @@ def test_rgb_to_hls(rgb, hls) -> None: assert terminal.HSL.from_rgb(rgb) == hls +@pytest.mark.parametrize( + 'rgb, expected', + [ + (terminal.RGB(255, 0, 0), 1), + (terminal.RGB(128, 0, 0), 1), + (terminal.RGB(0, 128, 0), 2), + (terminal.RGB(0, 0, 128), 4), + (terminal.RGB(128, 128, 0), 3), + (terminal.RGB(0, 0, 0), 0), + (terminal.RGB(255, 255, 255), 7), + (terminal.RGB(127, 127, 127), 0), + ], +) +def test_rgb_to_ansi_16(rgb, expected) -> None: + # Regression: ``int(c / 255)`` is 1 only when a channel is exactly 255, so + # every mid-intensity colour (e.g. maroon 128,0,0) collapsed to black. A + # per-channel threshold at 128 maps each channel to its own bit. + assert rgb.to_ansi_16 == expected + + @pytest.mark.parametrize( 'text, fg, bg, fg_none, bg_none, percentage, expected', [ From e336448c622cd2ce01c4e751857b27202a3b94da Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 17:17:17 +0200 Subject: [PATCH 08/24] fix(terminal): gate Color.ansi xterm index on real 256-colour support Two defects in Color.ansi: - `if self.xterm:` was falsy for index 0 (Black), so Black fell through to the RGB fallback and emitted the wrong code. - A registered 256-colour xterm index was emitted even on 16-colour (XTERM) terminals, which cannot address it. Use `self.xterm is not None` and only take the xterm index when COLOR_SUPPORT is XTERM_256 or better; 16-colour terminals now derive the code from to_ansi_16. --- progressbar/terminal/base.py | 9 ++++++++- tests/test_color.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index 53e2d315..b8736846 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -407,7 +407,14 @@ def ansi(self) -> types.Optional[str]: ): # pragma: no branch return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}' - if self.xterm: # pragma: no branch + # The registered xterm index is a 256-colour value, so only use it when + # the terminal actually supports 256 colours. ``is not None`` (not a + # truthiness test) so index 0 (Black) is honoured rather than falling + # through to the RGB fallback. + if ( + self.xterm is not None + and env.COLOR_SUPPORT >= env.ColorSupport.XTERM_256 + ): # pragma: no branch color = self.xterm elif ( env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 diff --git a/tests/test_color.py b/tests/test_color.py index 1ba2d287..cad44dfa 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -425,6 +425,39 @@ def test_ansi_color(monkeypatch) -> None: assert color.ansi is not None or color_support == env.ColorSupport.NONE +def test_color_ansi_respects_support_level(monkeypatch) -> None: + # A registered colour with an xterm index and a non-black RGB. + color = terminal.Color( + terminal.RGB(128, 0, 0), + terminal.HSL(0, 100, 25), + 'maroon-test', + 52, + ) + + # 256-colour terminal: the registered xterm index is used verbatim. + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.XTERM_256) + assert color.ansi == '5;52' + + # 16-colour terminal: the 256-colour xterm index must NOT leak through; + # derive a 16-colour code from the RGB via to_ansi_16 instead. + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.XTERM) + assert color.ansi == f'5;{color.rgb.to_ansi_16}' + assert color.ansi != '5;52' + + +def test_color_ansi_black_xterm_zero(monkeypatch) -> None: + # Regression: ``if self.xterm:`` is falsy for index 0 (Black), so Black + # fell through to the RGB fallback instead of using its xterm index. + black = terminal.Color( + terminal.RGB(0, 0, 0), + terminal.HSL(0, 0, 0), + 'black-test', + 0, + ) + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.XTERM_256) + assert black.ansi == '5;0' + + def test_sgr_call() -> None: assert progressbar.terminal.encircled('test') == '\x1b[52mtest\x1b[54m' From c2fc93b98f7c626b2d720345f33e041f3bde7220 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 17:18:10 +0200 Subject: [PATCH 09/24] fix(fast): use a 4-char spinner constant instead of a 5-char raw literal r'|/-\\' keeps both backslashes, so the literal was 5 chars and the four-frame cycle depended on a hardcoded `% 4`. Extract a module-level _SPINNER_FRAMES (plain 4-char literal) and index by its length. --- progressbar/fast.py | 7 ++++++- tests/test_fast_default.py | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/progressbar/fast.py b/progressbar/fast.py index 8e128d88..65d029be 100644 --- a/progressbar/fast.py +++ b/progressbar/fast.py @@ -12,6 +12,11 @@ #: present it replaces the pure-Python formatter below. Wired in a later task. _format_fast_line: typing.Callable[[FastProgressBar], str] | None = None +#: Spinner frames cycled for unknown-length bars: bar, forward slash, dash, +#: back slash. A plain (non-raw) literal so the escape is a single ``\`` and +#: the string is exactly four characters. +_SPINNER_FRAMES: str = '|/-\\' + def _format_seconds(seconds: float) -> str: """Render elapsed/ETA seconds as H:MM:SS, matching the Timer widget.""" @@ -50,7 +55,7 @@ def _pure_format_fast_line(bar: FastProgressBar) -> str: return f'{prefix}{left}{barstr}{right}{suffix}' # Unknown length: spinner + count + elapsed (no bar/eta). - spinner = r'|/-\\'[int(elapsed * 4) % 4] + spinner = _SPINNER_FRAMES[int(elapsed * 4) % len(_SPINNER_FRAMES)] item_count = value - min_value + 1 return ( f'{prefix}{spinner} {item_count} Elapsed Time: {elapsed_text}{suffix}' diff --git a/tests/test_fast_default.py b/tests/test_fast_default.py index 65781dad..b9fc8700 100644 --- a/tests/test_fast_default.py +++ b/tests/test_fast_default.py @@ -59,6 +59,33 @@ def test_fast_format_line_with_eta_calculation(): assert 'ETA: --:--:--' not in line +def test_fast_spinner_frames_cycle(): + """The spinner is exactly four frames and cycles through all of them. + + Regression: the raw literal ``r'|/-\\'`` is 5 chars long (the escape is not + collapsed), so the intended four-frame cycle was fragile and length-coupled + to a hardcoded ``% 4``. + """ + from datetime import datetime, timedelta + + assert len(fast_module._SPINNER_FRAMES) == 4 + assert set(fast_module._SPINNER_FRAMES) == set('|/-\\') + + fd = TTY() + bar = fast_module.FastProgressBar( + max_value=progressbar.UnknownLength, fd=fd + ) + bar.start_time = datetime(2020, 1, 1) + seen = [] + for quarter in range(4): + # Freeze elapsed at exact quarter-seconds so int(elapsed * 4) walks + # 0, 1, 2, 3 and must surface each distinct frame. + bar.end_time = bar.start_time + timedelta(seconds=quarter / 4) + seen.append(bar._format_line().lstrip()[0]) + + assert seen == ['|', '/', '-', '\\'] + + def test_fast_format_line_uses_native_hook(monkeypatch): """The native `_format_fast_line` hook takes precedence when set.""" From e99c3dbe54be99e0a1b6eea8245c63899ac44fd2 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 17:20:36 +0200 Subject: [PATCH 10/24] fix(widgets): validate MultiRangeBar fill with ValueError not assert The fill-branch check used a bare `assert`, which `python -O` strips, so an invalid multi-char fill would silently corrupt the rendered bar. Raise a ValueError with the offending value instead. --- progressbar/widgets.py | 5 ++++- tests/test_widgets.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 266d89a8..88a1fee5 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1269,7 +1269,10 @@ def __call__( middle += item_width * marker else: fill = converters.to_unicode(self.fill(progress, data, width)) - assert progress.custom_len(fill) == 1 + if progress.custom_len(fill) != 1: + raise ValueError( + f'Fill is required to be 1 char, got {fill!r}' + ) middle = fill * width return left + middle + right diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 6d6314e7..f31bc7c9 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -50,6 +50,29 @@ def test_multi_range_bar_rejects_multichar_marker() -> None: bar.finish(dirty=True) +def test_multi_range_bar_rejects_multichar_fill() -> None: + # Item 4: the fill path validates the fill width; a 2-char fill must raise + # ValueError rather than a stripped-under-O assert. Non-empty amounts keep + # the initial render on the marker branch; emptying them forces the + # zero-sum ``else`` (fill) branch on the direct call. + widget = progressbar.MultiRangeBar( + 'amounts', markers=[' ', '#'], fill='xx' + ) + bar = progressbar.ProgressBar( + widgets=[widget], + variables={'amounts': [1, 0]}, + max_value=10, + fd=io.StringIO(), + term_width=60, + ) + bar.start() + data = bar.data() + data['variables'] = {'amounts': []} + with pytest.raises(ValueError): + widget(bar, data, width=20) + bar.finish(dirty=True) + + def test_widgets_small_values() -> None: widgets = [ 'Test: ', From 1ad77b282452f2245138973a3cec0d5b11fc1322 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Thu, 2 Jul 2026 17:21:06 +0200 Subject: [PATCH 11/24] docs(widgets): note JobStatusBar.job_markers is vestigial Document that the attribute is kept only for backwards compatibility; per-run marker state now lives in progress.extra (get_job_markers) and this attribute is no longer read or written during rendering. --- progressbar/widgets.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index 88a1fee5..c57a0519 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1584,6 +1584,13 @@ class JobStatusBar(Bar, VariableMixin): failure_bg_color: terminal.Color | None = None failure_marker: str = 'X' job_markers: list[str] + """Retained for backwards compatibility only. + + Per-run marker state now lives in ``progress.extra`` (see + :py:meth:`get_job_markers`) so a single widget shared by multiple bars no + longer interleaves their markers. This attribute is no longer read or + updated during rendering. + """ def __init__( self, From 84b9413f4a77950efdb5622c12df4654a421a9e5 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 4 Jul 2026 13:07:46 +0200 Subject: [PATCH 12/24] fix(bar): replace _needs_update suppress(Exception) with explicit guards The width-threshold math ran inside contextlib.suppress(Exception), so any unexpected failure silently disabled redraws. Explicit guards for the known-incomplete states (no value drawn yet, no usable term_width, no nonzero max_value) preserve the exact prior False results while letting genuine bugs propagate. Also drops the per-call context-manager allocation from the hot path. --- progressbar/bar.py | 22 ++++++++--- tests/test_needs_update.py | 78 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 tests/test_needs_update.py diff --git a/progressbar/bar.py b/progressbar/bar.py index 3465ba98..c1a61b4d 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -1106,13 +1106,23 @@ def _needs_update(self): # limited by the min_poll_interval check above) return self.value != self._last_drawn_value - # Update if value increment is not large enough to - # add more bars to progressbar (according to current - # terminal width) - with contextlib.suppress(Exception): + # Update if the value increment is large enough to add more bars + # to the progressbar (according to the current terminal width). + # While the state is incomplete -- nothing drawn yet, no usable + # terminal width, no (nonzero) max value -- there is no width + # threshold to compute and no redraw is due; those guards mirror + # what a `suppress(Exception)` used to swallow here. Anything else + # failing in this math is a real bug and should propagate instead + # of silently stopping redraws. + if ( + self.value is not None + and self._last_drawn_value is not None + and self.term_width + and self.max_value + ): divisor: float = self.max_value / self.term_width # type: ignore - value_divisor = self.value // divisor # type: ignore - pvalue_divisor = self._last_drawn_value // divisor # type: ignore + value_divisor = self.value // divisor + pvalue_divisor = self._last_drawn_value // divisor if value_divisor != pvalue_divisor: return True # No need to redraw yet diff --git a/tests/test_needs_update.py b/tests/test_needs_update.py new file mode 100644 index 00000000..572e82c4 --- /dev/null +++ b/tests/test_needs_update.py @@ -0,0 +1,78 @@ +"""`_needs_update` guard behavior. + +The width-threshold computation used to run inside +``contextlib.suppress(Exception)``, so any unexpected failure silently +disabled redraws. The known-legitimate incomplete states (no value drawn +yet, no terminal width, zero max value) must still return False exactly +as before, while genuinely unexpected errors now propagate. +""" + +from __future__ import annotations + +import io +import typing + +import pytest + +import progressbar + + +def _bar(**kwargs: typing.Any) -> progressbar.ProgressBar: + bar = progressbar.ProgressBar( + fd=io.StringIO(), + max_value=100, + term_width=20, + **kwargs, + ) + bar.start() + bar.update(50, force=True) + # Move the rate limiters out of the way (the constructor substitutes a + # default for poll_interval=None) so the width-threshold branch decides. + bar.poll_interval = None + bar._last_update_timer = -1e9 + return bar + + +def test_needs_update_crossing_width_threshold() -> None: + bar = _bar() + bar.value = 90 + assert bar._needs_update() is True + + +def test_needs_update_within_same_width_bucket() -> None: + bar = _bar() + assert bar._last_drawn_value is not None + bar.value = bar._last_drawn_value + assert bar._needs_update() is False + + +@pytest.mark.parametrize( + 'attribute, incomplete_value', + [ + ('value', None), + ('_last_drawn_value', None), + ('term_width', None), + ('term_width', 0), + ('max_value', None), + ('max_value', 0), + ], +) +def test_needs_update_incomplete_state_is_false( + attribute: str, + incomplete_value: typing.Any, +) -> None: + # Each of these used to raise inside the suppress() and fall through + # to False; the explicit guards must preserve that result. + bar = _bar() + bar.value = 90 + setattr(bar, attribute, incomplete_value) + assert bar._needs_update() is False + + +def test_needs_update_unexpected_error_propagates() -> None: + # A genuinely wrong type is a bug and must no longer pass silently. + bar = _bar() + bar.value = 90 + bar.term_width = 'wide' # type: ignore[assignment] + with pytest.raises(TypeError): + bar._needs_update() From 7c9674e11a7a40930a57d347464f7dd64dc32929 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 4 Jul 2026 13:10:46 +0200 Subject: [PATCH 13/24] fix(env): narrow terminal-detection exception handlers is_ansi_terminal wrapped fd.isatty() probing in contextlib.suppress(Exception) and is_terminal in a bare except Exception, so genuine bugs (unexpected None, wrong types, programming errors) were silently downgraded to "not a terminal". Narrow both to the failures a stream can legitimately raise while probing: OSError (real I/O), ValueError (closed/detached file objects) and AttributeError (objects without isatty). Anything else now propagates. Drop the stale # pragma: no cover on the is_terminal isatty fallback now that tests exercise it. Update the existing test_utils RuntimeError case to OSError (a tolerated error); RuntimeError propagation is covered in the new tests/test_env_detection.py. --- progressbar/env.py | 17 ++++--- tests/test_env_detection.py | 95 +++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 6 ++- 3 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 tests/test_env_detection.py diff --git a/progressbar/env.py b/progressbar/env.py index 14d92dfc..225dc23f 100644 --- a/progressbar/env.py +++ b/progressbar/env.py @@ -121,8 +121,11 @@ def is_ansi_terminal( # is going to return False if the instance has been overridden and # isatty has not been defined we have no way of knowing so we will not # use ansi. ansi terminals will typically define one of the 2 - # environment variables. - with contextlib.suppress(Exception): + # environment variables. Only the errors a stream legitimately + # produces are treated as "not a terminal": OSError (real I/O), + # ValueError (closed/detached file objects) and AttributeError + # (objects without isatty). Anything else is a bug and propagates. + with contextlib.suppress(OSError, ValueError, AttributeError): is_tty: bool = fd.isatty() # Try and match any of the huge amount of Linux/Unix ANSI consoles if is_tty and ANSI_TERM_RE.match(os.environ.get('TERM', '')): @@ -155,12 +158,14 @@ def is_terminal( # Allow a environment variable override is_terminal = env_flag('PROGRESSBAR_IS_TERMINAL', None) - if is_terminal is None: # pragma: no cover - # Bare except because a lot can go wrong on different systems. If we do - # get a TTY we know this is a valid terminal + if is_terminal is None: + # If we do get a TTY we know this is a valid terminal. Streams can + # legitimately fail with OSError (real I/O), ValueError (closed or + # detached file objects) or AttributeError (no isatty at all); + # anything else is a bug and propagates. try: is_terminal = fd.isatty() - except Exception: + except (OSError, ValueError, AttributeError): is_terminal = False return is_terminal diff --git a/tests/test_env_detection.py b/tests/test_env_detection.py new file mode 100644 index 00000000..8a393a0f --- /dev/null +++ b/tests/test_env_detection.py @@ -0,0 +1,95 @@ +"""Terminal-detection error handling in `progressbar.env`. + +`is_ansi_terminal` and `is_terminal` used to wrap their `fd.isatty()` +probing in blanket exception handlers. Only the failures a stream can +legitimately produce — `OSError` (real I/O), `ValueError` (closed or +detached file objects), `AttributeError` (objects without `isatty`) — +may be treated as "not a terminal"; anything else is a bug and must +propagate. +""" + +from __future__ import annotations + +import typing + +import pytest + +from progressbar import env + + +class RaisingFd: + def __init__(self, error: Exception) -> None: + self._error = error + + def isatty(self) -> bool: + raise self._error + + def write(self, value: str) -> None: # pragma: no cover - never called + pass + + +class TtyFd: + def __init__(self, tty: bool) -> None: + self._tty = tty + + def isatty(self) -> bool: + return self._tty + + def write(self, value: str) -> None: # pragma: no cover - never called + pass + + +@pytest.fixture +def clean_environment(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ('PROGRESSBAR_IS_TERMINAL', 'ANSICON', 'TERM'): + monkeypatch.delenv(name, raising=False) + + +@pytest.mark.parametrize( + 'error', [OSError('io'), ValueError('closed'), AttributeError('no tty')] +) +def test_ansi_detection_tolerates_stream_errors( + error: Exception, + clean_environment: None, +) -> None: + fd = typing.cast(typing.IO[str], RaisingFd(error)) + assert env.is_ansi_terminal(fd) is None + + +def test_ansi_detection_propagates_unexpected_errors( + clean_environment: None, +) -> None: + fd = typing.cast(typing.IO[str], RaisingFd(RuntimeError('bug'))) + with pytest.raises(RuntimeError): + env.is_ansi_terminal(fd) + + +@pytest.mark.parametrize( + 'error', [OSError('io'), ValueError('closed'), AttributeError('no tty')] +) +def test_is_terminal_tolerates_stream_errors( + error: Exception, + clean_environment: None, +) -> None: + fd = typing.cast(typing.IO[str], RaisingFd(error)) + assert env.is_terminal(fd) is False + + +def test_is_terminal_propagates_unexpected_errors( + clean_environment: None, +) -> None: + fd = typing.cast(typing.IO[str], RaisingFd(RuntimeError('bug'))) + with pytest.raises(RuntimeError): + env.is_terminal(fd) + + +def test_is_terminal_plain_tty_without_ansi(clean_environment: None) -> None: + # A tty that matches no ANSI heuristics is still a terminal: the ANSI + # probe returns None and the final isatty() fallback decides. + fd = typing.cast(typing.IO[str], TtyFd(True)) + assert env.is_terminal(fd) is True + + +def test_is_terminal_non_tty(clean_environment: None) -> None: + fd = typing.cast(typing.IO[str], TtyFd(False)) + assert env.is_terminal(fd) is False diff --git a/tests/test_utils.py b/tests/test_utils.py index fd8ab866..f7b120d1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -110,8 +110,12 @@ def test_is_ansi_terminal(monkeypatch) -> None: monkeypatch.delenv('ANSICON') assert not progressbar.env.is_ansi_terminal(fd) + # A stream that legitimately fails to report tty-ness (e.g. OSError on + # a real I/O object) is simply not treated as an ANSI terminal. Only the + # narrowed OSError/ValueError/AttributeError set is tolerated; unexpected + # errors propagate (covered in tests/test_env_detection.py). def raise_error(): - raise RuntimeError('test') + raise OSError('test') fd.isatty = raise_error assert not progressbar.env.is_ansi_terminal(fd) From 2c8980806879f635a50ddc329324b0afd5ec6b23 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 4 Jul 2026 13:12:06 +0200 Subject: [PATCH 14/24] refactor(bar): drop redundant update() value guards update() guarded value assignment with `value is not None and value is not base.UnknownLength and isinstance(value, (int, float))`. The first two clauses are subsumed by the isinstance check: None fails it, and UnknownLength is a class (type FalseMeta), not an int/float instance. Verified in a REPL: isinstance(base.UnknownLength, (int, float)) is False. Reduce to the isinstance check alone; behavior is unchanged. Add tests/test_update_guard.py asserting update(None) and update(UnknownLength) still leave value/previous_value untouched. --- progressbar/bar.py | 10 ++++----- tests/test_update_guard.py | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 tests/test_update_guard.py diff --git a/progressbar/bar.py b/progressbar/bar.py index c1a61b4d..38b224bd 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -1194,11 +1194,11 @@ def update( if self.start_time is None: self.start() - if ( - value is not None - and value is not base.UnknownLength - and isinstance(value, (int, float)) - ): + # `isinstance(value, (int, float))` already excludes both `None` and + # the `UnknownLength` sentinel (a class, not a numeric instance), so + # the earlier explicit `is not None`/`is not UnknownLength` clauses + # were redundant. + if isinstance(value, (int, float)): if self.max_value is base.UnknownLength: # Can't compare against unknown lengths so just update pass diff --git a/tests/test_update_guard.py b/tests/test_update_guard.py new file mode 100644 index 00000000..de820c91 --- /dev/null +++ b/tests/test_update_guard.py @@ -0,0 +1,44 @@ +"""`ProgressBar.update()` value-guard behavior. + +The value-assignment block guards on ``isinstance(value, (int, float))``. +The two leading clauses it used to also carry -- ``value is not None`` and +``value is not base.UnknownLength`` -- are subsumed by that isinstance +check (``None`` and the ``UnknownLength`` sentinel class both fail it), so +``update(None)`` (a redraw tick) and ``update(UnknownLength)`` (the sentinel) +must leave ``value``/``previous_value`` untouched exactly as before. +""" + +from __future__ import annotations + +import io + +import progressbar +from progressbar import base + + +def _bar() -> progressbar.ProgressBar: + bar = progressbar.ProgressBar(fd=io.StringIO(), max_value=100).start() + bar.update(40) + assert bar.value == 40 + return bar + + +def test_update_none_is_a_tick_and_keeps_value() -> None: + bar = _bar() + previous = bar.previous_value + # A `None` value is a redraw tick, not a new value. + bar.update(None) + assert bar.value == 40 + assert bar.previous_value == previous + bar.finish() + + +def test_update_unknown_length_sentinel_keeps_value() -> None: + bar = _bar() + previous = bar.previous_value + # The `UnknownLength` sentinel is a class, not a numeric value, so the + # guard skips assignment rather than storing or comparing it. + bar.update(base.UnknownLength) + assert bar.value == 40 + assert bar.previous_value == previous + bar.finish() From bb044f9695f2485469fcfcf93b5cb4a89cd8bc39 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 4 Jul 2026 13:17:04 +0200 Subject: [PATCH 15/24] fix(multi): add join_timeout to bound clean MultiBar exit On a clean context-manager exit MultiBar.__exit__ called join() with timeout=None, waiting until every bar finished. A single never-finishing bar hung the program forever. Add a keyword-only join_timeout: timedelta | float | None = None constructor parameter (converted with the same python_utils.delta_to_seconds_or_none used for remove_finished). The clean __exit__ path now passes it to join(timeout=...); once it elapses the still-unfinished bars are abandoned and the daemon render thread is left running so the program can exit. Default None preserves the historical wait-forever behavior. Regenerate tests/api_surface_snapshot.json for the additive keyword-only parameter. --- progressbar/multi.py | 24 +++++++++++++++++++++++- tests/api_surface_snapshot.json | 4 ++-- tests/test_multibar.py | 20 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/progressbar/multi.py b/progressbar/multi.py index 5eda7eb2..5471df89 100644 --- a/progressbar/multi.py +++ b/progressbar/multi.py @@ -53,6 +53,18 @@ class SortKey(str, enum.Enum): class MultiBar(dict[str, bar.ProgressBar]): + """Render and manage multiple progressbars from background threads. + + On a clean context-manager exit the multibar waits for its render + thread via :meth:`join`. By default (``join_timeout=None``) that wait + is unbounded, so a bar that never finishes blocks the program forever. + Pass ``join_timeout`` (seconds, or a :class:`datetime.timedelta`) to + bound that wait: once it elapses any still-unfinished bars are + abandoned and the render thread -- a daemon -- is left running so the + program can exit. The default preserves the historical wait-forever + behavior. + """ + fd: typing.TextIO _buffer: io.StringIO @@ -73,6 +85,9 @@ class MultiBar(dict[str, bar.ProgressBar]): # updates update_interval: float remove_finished: float | None + #: Seconds to wait for the render thread on a clean context-manager + # exit before abandoning unfinished bars. `None` waits forever. + join_timeout: float | None #: The kwargs passed to the progressbar constructor progressbar_kwargs: dict[str, typing.Any] @@ -104,6 +119,8 @@ def __init__( sort_key: str | SortKey = SortKey.CREATED, sort_reverse: bool = True, sort_keyfunc: SortKeyFunc | None = None, + *, + join_timeout: timedelta | float | None = None, **progressbar_kwargs: typing.Any, ): self.fd = fd @@ -121,6 +138,9 @@ def __init__( self.remove_finished = python_utils.delta_to_seconds_or_none( remove_finished, ) + self.join_timeout = python_utils.delta_to_seconds_or_none( + join_timeout, + ) self.progressbar_kwargs = progressbar_kwargs @@ -412,7 +432,9 @@ def __exit__( traceback: types.TracebackType | None, ) -> bool | None: if exc_type is None: - self.join() + # Bound the wait so a never-finishing bar cannot hang a clean + # exit; `join_timeout=None` keeps the historical forever-wait. + self.join(timeout=self.join_timeout) else: # Don't wait for unfinished progressbars when an exception is # propagating; that would block forever diff --git a/tests/api_surface_snapshot.json b/tests/api_surface_snapshot.json index baa0b147..7b62bba7 100644 --- a/tests/api_surface_snapshot.json +++ b/tests/api_surface_snapshot.json @@ -22,7 +22,7 @@ "GranularBar": "class(markers=?, left=?, right=?, **kwargs)", "JobStatusBar": "class(name, left=?, right=?, fill=?, fill_left=?, success_fg_color=?, success_bg_color=?, success_marker=?, failure_fg_color=?, failure_bg_color=?, failure_marker=?, **kwargs)", "LineOffsetStreamWrapper": "class(lines=?, stream=?)", - "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, **progressbar_kwargs)", + "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, *, join_timeout=?, **progressbar_kwargs)", "MultiProgressBar": "class(name, markers=?, **kwargs)", "MultiRangeBar": "class(name, markers, **kwargs)", "NullBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", @@ -95,7 +95,7 @@ "timedelta": "classsignature-unavailable" }, "progressbar.multi": { - "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, **progressbar_kwargs)", + "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, *, join_timeout=?, **progressbar_kwargs)", "SortKey": "class(*values)", "SortKeyFunc": "callable(*args, **kwargs)", "annotations": "_Feature", diff --git a/tests/test_multibar.py b/tests/test_multibar.py index daf55a17..30f98d53 100644 --- a/tests/test_multibar.py +++ b/tests/test_multibar.py @@ -257,6 +257,26 @@ def test_multibar_threads() -> None: multibar.render(force=True) +def test_multibar_join_timeout_abandons_unfinished_bar() -> None: + # A never-finishing bar must not hang a clean context-manager exit when + # join_timeout is set; the unfinished bar is abandoned once it elapses. + def exit_context() -> None: + with progressbar.MultiBar( + fd=io.StringIO(), join_timeout=0.1 + ) as multibar: + bar = progressbar.ProgressBar(max_value=10) + multibar['stuck'] = bar + bar.start() + bar.update(5) # never reaches max_value / finish() + + thread = threading.Thread(target=exit_context, daemon=True) + thread.start() + thread.join(timeout=5) + assert not thread.is_alive(), ( + 'clean context exit blocked despite join_timeout' + ) + + def test_multibar_instances_do_not_share_thread_state() -> None: # Regression: D1 - thread primitives were class attributes shared # between all MultiBar instances. From 5a866cc3aad60e91c1df9a0fca86ba7c12fecdeb Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 4 Jul 2026 13:20:19 +0200 Subject: [PATCH 16/24] test(multi): make test_multibar_print deterministic test_multibar_print drove both the inter-iteration sleep and the per-thread print decision off the global random.random(). Under the 100% branch-coverage gate a run where the print guard never fired could flakily fail, and workers outliving the multibar occasionally raised "I/O operation on closed file" from a torn-down fd. Give each worker its own seeded random.Random(seed) so its draws are reproducible regardless of thread scheduling, iterate probabilities (0.0, 0.5, 1.0) so the never/always threads deterministically hit both sides of the print guard every run (verified the 0.5 seeds also hit both), and join the workers before leaving the context so none writes to a closed stream. Shorter, deterministic sleeps keep the run fast. --- tests/test_multibar.py | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/tests/test_multibar.py b/tests/test_multibar.py index 30f98d53..13333bb6 100644 --- a/tests/test_multibar.py +++ b/tests/test_multibar.py @@ -167,29 +167,49 @@ def test_multibar_print() -> None: bars = 5 n = 10 - def print_sometimes(bar, probability): + def print_sometimes(bar, probability, seed): + # A per-thread seeded RNG keeps the interleaving jittery but fully + # reproducible: a thread only ever touches its own RNG, so thread + # scheduling cannot reorder its draws. Combined with the never/always + # threads below this makes the print-guard branch coverage + # deterministic instead of flakily depending on the global RNG. + rng = random.Random(seed) for i in bar(range(n)): - # Sleep up to 0.1 seconds - time.sleep(random.random() * 0.1) + # Sleep a small, deterministic fraction of a second + time.sleep(rng.random() * 0.01) # print messages at random intervals to show how extra output works - if random.random() < probability: + if rng.random() < probability: bar.print('random message for bar', bar, i) with progressbar.MultiBar() as multibar: + seed = 0 + threads: list[threading.Thread] = [] for i in range(bars): # Get a progressbar bar = multibar[f'Thread label here {i}'] bar.max_error = False - # Create a thread and pass the progressbar - # Print never, sometimes and always - threading.Thread(target=print_sometimes, args=(bar, 0)).start() - threading.Thread(target=print_sometimes, args=(bar, 0.5)).start() - threading.Thread(target=print_sometimes, args=(bar, 1)).start() + # Create a thread and pass the progressbar. Print never (0.0), + # sometimes (0.5) and always (1.0): the 0.0 and 1.0 threads + # deterministically exercise both sides of the print guard on + # every run, independent of the seeded middle thread. + for probability in (0.0, 0.5, 1.0): + thread = threading.Thread( + target=print_sometimes, args=(bar, probability, seed) + ) + thread.start() + threads.append(thread) + seed += 1 for i in range(5): multibar.print(f'{i}', flush=False) + # Join the workers before leaving the context so none outlives the + # multibar (a stray thread writing to a torn-down fd raised + # "I/O operation on closed file") and every print branch has run. + for thread in threads: + thread.join() + # Note: MultiBar inherits from dict, so update() would be # dict.update and insert bogus entries; render() is intended here multibar.render(force=True, flush=False) From 43f11d12e3bfd15b68c517aae771db559a2a368a Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 4 Jul 2026 13:26:45 +0200 Subject: [PATCH 17/24] test(multi): stop abandoned render thread in join_timeout test The join_timeout test (previous commit) intentionally leaves an unfinished bar, so join_timeout abandons the MultiBar render thread. That daemon thread keeps rendering for the rest of the process and raced with later tests -- intermittently surfacing freezegun's threaded IndexError and unrelated TypeError failures. Hold the MultiBar reference and call stop() once the prompt exit is verified so the render loop terminates and cannot pollute other tests. Behavior under test is unchanged. --- tests/test_multibar.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_multibar.py b/tests/test_multibar.py index 13333bb6..1326c04d 100644 --- a/tests/test_multibar.py +++ b/tests/test_multibar.py @@ -280,21 +280,26 @@ def test_multibar_threads() -> None: def test_multibar_join_timeout_abandons_unfinished_bar() -> None: # A never-finishing bar must not hang a clean context-manager exit when # join_timeout is set; the unfinished bar is abandoned once it elapses. + multibar = progressbar.MultiBar(fd=io.StringIO(), join_timeout=0.1) + bar = progressbar.ProgressBar(max_value=10) + multibar['stuck'] = bar + def exit_context() -> None: - with progressbar.MultiBar( - fd=io.StringIO(), join_timeout=0.1 - ) as multibar: - bar = progressbar.ProgressBar(max_value=10) - multibar['stuck'] = bar + with multibar: bar.start() bar.update(5) # never reaches max_value / finish() thread = threading.Thread(target=exit_context, daemon=True) thread.start() thread.join(timeout=5) - assert not thread.is_alive(), ( - 'clean context exit blocked despite join_timeout' - ) + exited = not thread.is_alive() + + # join_timeout leaves the render thread (a daemon) running so the + # program can exit; stop it explicitly so it cannot leak into and + # pollute later tests in the same process. + multibar.stop() + + assert exited, 'clean context exit blocked despite join_timeout' def test_multibar_instances_do_not_share_thread_state() -> None: From ea7267d83c718af57d710703bad2ee653f9ca4d1 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 4 Jul 2026 13:55:29 +0200 Subject: [PATCH 18/24] fix(test): deregister needs_update bars from global capture registry test_needs_update starts bars (registering them as global capture listeners via streams.start_capturing) but never finish()es them, and one deliberately sets term_width='wide' to prove the width-threshold math now propagates. That poisoned bar lingered in streams.listeners, so when a *later* test wrote a newline to the captured stream -- WrappingIO.write calls update() on every listener -- the abandoned bar hit the int/str division from commit 84b9413 and raised, intermittently failing test_examples and test_no_newlines (the failure surfaced or hid depending on freezegun-driven redraw timing). Add an autouse fixture that deregisters whatever each test started, so a bar driven into an invalid state can never poison another test. --- tests/test_needs_update.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_needs_update.py b/tests/test_needs_update.py index 572e82c4..f1aebeca 100644 --- a/tests/test_needs_update.py +++ b/tests/test_needs_update.py @@ -15,6 +15,20 @@ import pytest import progressbar +from progressbar import utils + + +@pytest.fixture(autouse=True) +def _deregister_started_bars() -> typing.Iterator[None]: + # start() registers each bar as a global capture listener (so writes to + # the wrapped stream redraw it). These tests deliberately drive bars into + # invalid states -- e.g. a non-numeric term_width -- and never finish() + # them. Deregister whatever this test started so a poisoned bar cannot be + # update()d when a *later* test writes a newline to the captured stream. + before = set(utils.streams.listeners) + yield + for bar in list(utils.streams.listeners - before): + utils.streams.stop_capturing(bar) def _bar(**kwargs: typing.Any) -> progressbar.ProgressBar: From b72f469ec85a4e7d7d6a3d0fba291a882d39d8e3 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 02:11:09 +0200 Subject: [PATCH 19/24] fix(terminal): leave text unstyled when Color.ansi has no representation With COLOR_SUPPORT at NONE, Color.ansi is now None (correct since the support-gating fix) but SGRColor formatted it into the escape code, emitting a malformed '\x1b[38;Nonem' whose tail leaked into visible output as 'onem' in pty-based tests. Without a usable representation the text now passes through completely unstyled. --- progressbar/terminal/base.py | 12 ++++++++++++ tests/test_color.py | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index b8736846..e95839dd 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -639,6 +639,18 @@ def __init__(self, color: Color, start_code: int, end_code: int) -> None: self._color = color super().__init__(start_code, end_code) + def __call__( + self, + text: str, + *args: typing.Any, + ) -> str: + if self._color.ansi is None: + # No usable color representation for this terminal (e.g. color + # support is NONE): leave the text unstyled instead of emitting + # a malformed escape code containing the literal string 'None'. + return text + return super().__call__(text, *args) + @property def _start_template(self): return CSI.__call__(self, self._start_code, self._color.ansi) diff --git a/tests/test_color.py b/tests/test_color.py index cad44dfa..e3558118 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -515,3 +515,16 @@ def test_gradient_colors_override_is_per_instance() -> None: assert a._gradient_colors['fg'] == colors.gradient assert b._gradient_colors == class_default assert a._gradient_colors is not override + + +def test_sgr_color_without_ansi_leaves_text_unstyled(monkeypatch) -> None: + # Regression: with COLOR_SUPPORT NONE, Color.ansi is None and SGRColor + # rendered a malformed '\x1b[38;Nonem' escape whose tail leaked into + # visible output as 'onem'. Without a usable color representation the + # text must pass through completely unstyled. + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.NONE) + green = progressbar.terminal.colors.green + assert green.fg('X') == 'X' + assert green.bg('X') == 'X' + assert green.underline('X') == 'X' + assert 'None' not in green.fg('X') From 0412dba0b7f2c74646ba5b917607fb4ab307cf80 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 02:11:10 +0200 Subject: [PATCH 20/24] test: make API snapshot version-stable; single import style Describe typing aliases, enums (by member list) and stdlib re-exports with version-stable descriptors: typing.Union aliases changed type in 3.14, enum constructor signatures are metaclass artifacts, and TracebackType's signature availability varies. Snapshot regenerated on 3.14 and verified identical on 3.10. Use a single import style for progressbar in the new test modules so CodeQL's import-and-import-from check stays quiet (same alias pattern as test_fast_default.py). --- tests/api_surface_snapshot.json | 80 ++++++++++++++++----------------- tests/test_api_surface.py | 22 +++++++++ tests/test_needs_update.py | 9 ++-- tests/test_subclass_compat.py | 6 ++- tests/test_update_guard.py | 6 ++- 5 files changed, 77 insertions(+), 46 deletions(-) diff --git a/tests/api_surface_snapshot.json b/tests/api_surface_snapshot.json index 7b62bba7..e0b19d31 100644 --- a/tests/api_surface_snapshot.json +++ b/tests/api_surface_snapshot.json @@ -34,7 +34,7 @@ "SimpleProgress": "class(format=?, **kwargs)", "SmoothingAlgorithm": "class(**kwargs)", "SmoothingETA": "class(smoothing_algorithm=?, smoothing_parameters=?, **kwargs)", - "SortKey": "class(*values)", + "SortKey": "enum(CREATED,LABEL,VALUE,PERCENTAGE)", "Timer": "class(format=?, **kwargs)", "UnknownLength": "class()", "Variable": "class(name, format=?, width=?, precision=?, **kwargs)", @@ -50,30 +50,30 @@ "ExponentialMovingAverage": "class(alpha=?)", "SmoothingAlgorithm": "class(**kwargs)", "annotations": "_Feature", - "timedelta": "classsignature-unavailable" + "timedelta": "re-export" }, "progressbar.bar": { "DataTransferBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", "DefaultFdMixin": "class(fd=?, is_terminal=?, line_breaks=?, enable_colors=?, line_offset=?, **kwargs)", - "FrameType": "class()", + "FrameType": "re-export", "NullBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", - "NumberT": "class(x=?)", + "NumberT": "re-export", "ProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", "ProgressBarBase": "class(**kwargs)", "ProgressBarMixinBase": "class(**kwargs)", "ResizableMixin": "class(term_width=?, **kwargs)", "StdRedirectMixin": "class(redirect_stderr=?, redirect_stdout=?, redirect_blank_line=?, **kwargs)", - "T": "TypeVar", - "ValueT": "Union", + "T": "type-alias", + "ValueT": "type-alias", "annotations": "_Feature", - "datetime": "classsignature-unavailable", + "datetime": "re-export", "deepcopy": "callable(x, memo=?, _nil=?)", "logger": "Logger" }, "progressbar.base": { "FalseMeta": "classsignature-unavailable", - "IO": "class()", - "TextIO": "class()", + "IO": "re-export", + "TextIO": "re-export", "Undefined": "class()", "UnknownLength": "class()" }, @@ -81,7 +81,7 @@ "ANSI_TERMS": "tuple", "ANSI_TERM_RE": "Pattern", "COLOR_SUPPORT": "ColorSupport", - "ColorSupport": "class(*values)", + "ColorSupport": "enum(NONE,XTERM,XTERM_256,XTERM_TRUECOLOR,WINDOWS)", "JUPYTER": "bool", "annotations": "_Feature", "env_flag": "callable(name, default=?)", @@ -91,18 +91,18 @@ "progressbar.fast": { "FastProgressBar": "class(min_value=?, max_value=?, widgets=?, left_justify=?, initial_value=?, poll_interval=?, widget_kwargs=?, custom_len=?, max_error=?, prefix=?, suffix=?, variables=?, min_poll_interval=?, **kwargs)", "annotations": "_Feature", - "datetime": "classsignature-unavailable", - "timedelta": "classsignature-unavailable" + "datetime": "re-export", + "timedelta": "re-export" }, "progressbar.multi": { "MultiBar": "class(bars=?, fd=?, prepend_label=?, append_label=?, label_format=?, initial_format=?, finished_format=?, update_interval=?, show_initial=?, show_finished=?, remove_finished=?, sort_key=?, sort_reverse=?, sort_keyfunc=?, *, join_timeout=?, **progressbar_kwargs)", - "SortKey": "class(*values)", - "SortKeyFunc": "callable(*args, **kwargs)", + "SortKey": "enum(CREATED,LABEL,VALUE,PERCENTAGE)", + "SortKeyFunc": "type-alias", "annotations": "_Feature", - "timedelta": "classsignature-unavailable" + "timedelta": "re-export" }, "progressbar.shortcuts": { - "T": "TypeVar", + "T": "type-alias", "annotations": "_Feature", "progressbar": "callable(iterator, min_value=?, max_value=?, widgets=?, prefix=?, suffix=?, fast=?, **kwargs)" }, @@ -120,7 +120,7 @@ "CSI": "class(code, *default_args)", "CSINoArg": "class(code, *default_args)", "CUP": "callable(*args)", - "ClassVar": "callable(*args, **kwds)", + "ClassVar": "type-alias", "Color": "class(rgb, hls, name, xterm)", "ColorBase": "class()", "ColorGradient": "class(*colors, interpolate=?)", @@ -130,13 +130,13 @@ "ESC": "str", "HIDE_CURSOR": "callable()", "HSL": "class(hue, saturation, lightness)", - "Iterable": "class()", - "Iterator": "class()", + "Iterable": "re-export", + "Iterator": "re-export", "LEFT": "callable(*args)", "LastLineStream": "class(stream)", "LineOffsetStreamWrapper": "class(lines=?, stream=?)", "NEXT_LINE": "callable(*args)", - "OptionalColor": "Union", + "OptionalColor": "type-alias", "PREVIOUS_LINE": "callable(*args)", "RESTORE_CURSOR": "callable()", "RGB": "class(red, green, blue)", @@ -148,15 +148,15 @@ "SGRColor": "class(color, start_code, end_code)", "SHOW_CURSOR": "callable()", "TextIOOutputWrapper": "class(stream)", - "TracebackType": "class(tb_next, tb_frame, tb_lasti, tb_lineno)", + "TracebackType": "re-export", "UP": "callable(*args)", "WindowsColor": "class(color)", - "WindowsColors": "class(*values)", + "WindowsColors": "enum(BLACK,BLUE,GREEN,CYAN,RED,MAGENTA,YELLOW,GREY,INTENSE_BLACK,INTENSE_BLUE,INTENSE_GREEN,INTENSE_CYAN,INTENSE_RED,INTENSE_MAGENTA,INTENSE_YELLOW,INTENSE_WHITE)", "annotations": "_Feature", "apply_colors": "callable(text, percentage=?, *, fg=?, bg=?, fg_none=?, bg_none=?, **kwargs)", "bold": "callable(text, *args)", "clear_line": "callable(n)", - "defaultdict": "classsignature-unavailable", + "defaultdict": "re-export", "double_underline": "callable(text, *args)", "encircled": "callable(text, *args)", "faint": "callable(text, *args)", @@ -186,7 +186,7 @@ "CSI": "class(code, *default_args)", "CSINoArg": "class(code, *default_args)", "CUP": "callable(*args)", - "ClassVar": "callable(*args, **kwds)", + "ClassVar": "type-alias", "Color": "class(rgb, hls, name, xterm)", "ColorBase": "class()", "ColorGradient": "class(*colors, interpolate=?)", @@ -198,7 +198,7 @@ "HSL": "class(hue, saturation, lightness)", "LEFT": "callable(*args)", "NEXT_LINE": "callable(*args)", - "OptionalColor": "Union", + "OptionalColor": "type-alias", "PREVIOUS_LINE": "callable(*args)", "RESTORE_CURSOR": "callable()", "RGB": "class(red, green, blue)", @@ -211,12 +211,12 @@ "SHOW_CURSOR": "callable()", "UP": "callable(*args)", "WindowsColor": "class(color)", - "WindowsColors": "class(*values)", + "WindowsColors": "enum(BLACK,BLUE,GREEN,CYAN,RED,MAGENTA,YELLOW,GREY,INTENSE_BLACK,INTENSE_BLUE,INTENSE_GREEN,INTENSE_CYAN,INTENSE_RED,INTENSE_MAGENTA,INTENSE_YELLOW,INTENSE_WHITE)", "annotations": "_Feature", "apply_colors": "callable(text, percentage=?, *, fg=?, bg=?, fg_none=?, bg_none=?, **kwargs)", "bold": "callable(text, *args)", "clear_line": "callable(n)", - "defaultdict": "classsignature-unavailable", + "defaultdict": "re-export", "double_underline": "callable(text, *args)", "encircled": "callable(text, *args)", "faint": "callable(text, *args)", @@ -447,23 +447,23 @@ "yellow4": "callable(value)" }, "progressbar.terminal.stream": { - "Iterable": "class()", - "Iterator": "class()", + "Iterable": "re-export", + "Iterator": "re-export", "LastLineStream": "class(stream)", "LineOffsetStreamWrapper": "class(lines=?, stream=?)", "TextIOOutputWrapper": "class(stream)", - "TracebackType": "class(tb_next, tb_frame, tb_lasti, tb_lineno)", + "TracebackType": "re-export", "annotations": "_Feature" }, "progressbar.utils": { "@py_assert1": "NoneType", "@py_assert2": "NoneType", "AttributeDict": "classsignature-unavailable", - "Iterable": "class()", - "Iterator": "class()", + "Iterable": "re-export", + "Iterator": "re-export", "StreamWrapper": "class()", - "StringT": "TypeVar", - "TracebackType": "class(tb_next, tb_frame, tb_lasti, tb_lineno)", + "StringT": "type-alias", + "TracebackType": "re-export", "WrappingIO": "class(target, capturing=?, listeners=?)", "annotations": "_Feature", "deltas_to_seconds": "callable(*deltas, default=?)", @@ -485,11 +485,11 @@ "AutoWidthWidgetBase": "class(*args, fixed_colors=?, gradient_colors=?, **kwargs)", "Bar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", "BouncingBar": "class(marker=?, left=?, right=?, fill=?, fill_left=?, marker_wrap=?, **kwargs)", - "ClassVar": "callable(*args, **kwds)", + "ClassVar": "type-alias", "ColoredMixin": "class()", "Counter": "class(format=?, **kwargs)", "CurrentTime": "class(format=?, microseconds=?, **kwargs)", - "Data": "callable(*args, **kwargs)", + "Data": "type-alias", "DataSize": "class(variable=?, format=?, unit=?, prefixes=?, **kwargs)", "DynamicMessage": "class(name, format=?, width=?, precision=?, **kwargs)", "ETA": "class(format_not_started=?, format_finished=?, format=?, format_zero=?, format_na=?, **kwargs)", @@ -497,7 +497,7 @@ "FormatCustomText": "class(format, mapping=?, **kwargs)", "FormatLabel": "class(format, **kwargs)", "FormatLabelBar": "class(format, **kwargs)", - "FormatString": "Union", + "FormatString": "type-alias", "FormatWidgetMixin": "class(format, new_style=?, **kwargs)", "GranularBar": "class(markers=?, left=?, right=?, **kwargs)", "GranularMarkers": "class()", @@ -514,9 +514,9 @@ "SamplesMixin": "class(samples=?, key_prefix=?, **kwargs)", "SimpleProgress": "class(format=?, **kwargs)", "SmoothingETA": "class(smoothing_algorithm=?, smoothing_parameters=?, **kwargs)", - "T": "TypeVar", - "TFixedColors": "classsignature-unavailable", - "TGradientColors": "classsignature-unavailable", + "T": "type-alias", + "TFixedColors": "type-alias", + "TGradientColors": "type-alias", "TimeSensitiveWidgetBase": "class(*args, fixed_colors=?, gradient_colors=?, **kwargs)", "Timer": "class(format=?, **kwargs)", "Variable": "class(name, format=?, width=?, precision=?, **kwargs)", diff --git a/tests/test_api_surface.py b/tests/test_api_surface.py index 95a70b70..24bd27b8 100644 --- a/tests/test_api_surface.py +++ b/tests/test_api_surface.py @@ -18,6 +18,7 @@ from __future__ import annotations +import enum import importlib import inspect import json @@ -78,7 +79,28 @@ def _describe_signature(obj: typing.Any) -> str: def _describe(obj: typing.Any) -> str: + # typing constructs (Union aliases, parameterized generics, TypeVars) + # change type/callability across Python versions (e.g. typing.Union + # aliases became instances of a Union class in 3.14), so they get one + # stable descriptor everywhere. + if ( + typing.get_origin(obj) is not None + or getattr(type(obj), '__module__', '') == 'typing' + ): + return 'type-alias' if inspect.isclass(obj): + if issubclass(obj, enum.Enum): + # Enum constructor signatures are metaclass artifacts that vary + # across Python versions; the compatibility contract is the + # member list. + enum_class = typing.cast('type[enum.Enum]', obj) + members = ','.join(member.name for member in enum_class) + return f'enum({members})' + if not getattr(obj, '__module__', '').startswith('progressbar'): + # Stdlib/third-party re-exports (TracebackType, timedelta, ...) + # picked up by the no-__all__ fallback: their signatures are not + # part of this package's contract and vary across versions. + return 're-export' return f'class{_describe_signature(obj)}' if callable(obj): return f'callable{_describe_signature(obj)}' diff --git a/tests/test_needs_update.py b/tests/test_needs_update.py index f1aebeca..5577d86a 100644 --- a/tests/test_needs_update.py +++ b/tests/test_needs_update.py @@ -15,7 +15,7 @@ import pytest import progressbar -from progressbar import utils +import progressbar.utils @pytest.fixture(autouse=True) @@ -25,10 +25,11 @@ def _deregister_started_bars() -> typing.Iterator[None]: # invalid states -- e.g. a non-numeric term_width -- and never finish() # them. Deregister whatever this test started so a poisoned bar cannot be # update()d when a *later* test writes a newline to the captured stream. - before = set(utils.streams.listeners) + streams = progressbar.utils.streams + before = set(streams.listeners) yield - for bar in list(utils.streams.listeners - before): - utils.streams.stop_capturing(bar) + for bar in list(streams.listeners - before): + streams.stop_capturing(bar) def _bar(**kwargs: typing.Any) -> progressbar.ProgressBar: diff --git a/tests/test_subclass_compat.py b/tests/test_subclass_compat.py index bfcfc919..684ce861 100644 --- a/tests/test_subclass_compat.py +++ b/tests/test_subclass_compat.py @@ -23,7 +23,11 @@ import pytest import progressbar -from progressbar import widgets +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 def _render( diff --git a/tests/test_update_guard.py b/tests/test_update_guard.py index de820c91..48457ac1 100644 --- a/tests/test_update_guard.py +++ b/tests/test_update_guard.py @@ -13,7 +13,11 @@ import io import progressbar -from progressbar import base +import progressbar.base + +# Alias (not a `from` import) so CodeQL doesn't flag `progressbar` as +# imported with both `import` and `import from`. +base = progressbar.base def _bar() -> progressbar.ProgressBar: From e072d48fed7534bce9d71842031a55ba54d7d2ac Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 02:20:03 +0200 Subject: [PATCH 21/24] fix(terminal): render registered colors when forced without support Color.ansi gated the registered xterm index on detected 256-color support, but callers can force colors (enable_colors=True, FORCE_COLOR) on terminals whose detection reports NONE -- rendering an SGR at all means the caller decided colors are wanted. Prefer the registered xterm index everywhere except true 16-color terminals (which still get the to_ansi_16 translation instead of a leaked 256-color index); only fully unregistered colors with no derivable code return None and pass text through unstyled. --- progressbar/terminal/base.py | 21 ++++++++++----------- tests/test_color.py | 36 +++++++++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index e95839dd..8a79fbce 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -407,21 +407,20 @@ def ansi(self) -> types.Optional[str]: ): # pragma: no branch return f'2;{self.rgb.red};{self.rgb.green};{self.rgb.blue}' - # The registered xterm index is a 256-colour value, so only use it when - # the terminal actually supports 256 colours. ``is not None`` (not a - # truthiness test) so index 0 (Black) is honoured rather than falling - # through to the RGB fallback. - if ( - self.xterm is not None - and env.COLOR_SUPPORT >= env.ColorSupport.XTERM_256 - ): # pragma: no branch + # A true 16-colour terminal must not be handed a 256-colour index, + # so translate through to_ansi_16 there. Everywhere else prefer the + # registered xterm index (``is not None`` so index 0/Black counts): + # rendering an SGR at all means the caller decided colours are + # wanted (e.g. forced via ``enable_colors``), even when the global + # detection reported no support. + if env.COLOR_SUPPORT is env.ColorSupport.XTERM: + color = self.rgb.to_ansi_16 + elif self.xterm is not None: color = self.xterm elif ( env.COLOR_SUPPORT is env.ColorSupport.XTERM_256 ): # pragma: no branch color = self.rgb.to_ansi_256 - elif env.COLOR_SUPPORT is env.ColorSupport.XTERM: # pragma: no branch - color = self.rgb.to_ansi_16 else: # pragma: no branch return None @@ -639,7 +638,7 @@ def __init__(self, color: Color, start_code: int, end_code: int) -> None: self._color = color super().__init__(start_code, end_code) - def __call__( + def __call__( # pyright: ignore[reportIncompatibleMethodOverride] self, text: str, *args: typing.Any, diff --git a/tests/test_color.py b/tests/test_color.py index e3558118..b3b77421 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -518,13 +518,31 @@ def test_gradient_colors_override_is_per_instance() -> None: def test_sgr_color_without_ansi_leaves_text_unstyled(monkeypatch) -> None: - # Regression: with COLOR_SUPPORT NONE, Color.ansi is None and SGRColor - # rendered a malformed '\x1b[38;Nonem' escape whose tail leaked into - # visible output as 'onem'. Without a usable color representation the - # text must pass through completely unstyled. + # Regression: when Color.ansi is None (no registered xterm index and no + # support level to derive a code from), SGRColor rendered a malformed + # '\x1b[38;Nonem' escape whose tail leaked into visible output as + # 'onem'. Without a usable color representation the text must pass + # through completely unstyled. monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.NONE) - green = progressbar.terminal.colors.green - assert green.fg('X') == 'X' - assert green.bg('X') == 'X' - assert green.underline('X') == 'X' - assert 'None' not in green.fg('X') + unregistered = terminal.Color( + terminal.RGB(1, 2, 3), + terminal.HSL(0, 0, 1), + None, + None, + ) + assert unregistered.ansi is None + assert unregistered.fg('X') == 'X' + assert unregistered.bg('X') == 'X' + assert unregistered.underline('X') == 'X' + + +def test_registered_color_renders_when_forced_without_support( + monkeypatch, +) -> None: + # Callers can force colors (enable_colors=True) on terminals whose + # support detection reports NONE; a registered color must still render + # its xterm index there instead of silently dropping the styling. + monkeypatch.setattr(env, 'COLOR_SUPPORT', env.ColorSupport.NONE) + green = colors.green + assert green.ansi == '5;2' + assert green.fg('X') == '\x1b[38;5;2mX\x1b[39m' From cdbd28edeea4854270802b881af080c7b12a80a8 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 02:20:03 +0200 Subject: [PATCH 22/24] fix(widgets): keep class-level mapping defaults in FormatCustomText dict(mapping or {}) discarded class-level mapping defaults declared by subclasses. Fall back to the class attribute when no mapping is passed, still copying so instance mutation never touches the shared default. --- progressbar/widgets.py | 6 ++++-- tests/test_custom_widgets.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index c57a0519..c035f463 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -1187,8 +1187,10 @@ def __init__( self.format = format # Always build a fresh per-instance dict so update_mapping() never # mutates the shared class-level default (which every other - # default-constructed instance would otherwise alias). - self.mapping = dict(mapping or {}) + # default-constructed instance would otherwise alias). Fall back to + # 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) diff --git a/tests/test_custom_widgets.py b/tests/test_custom_widgets.py index 23fb3945..ce9f24ca 100644 --- a/tests/test_custom_widgets.py +++ b/tests/test_custom_widgets.py @@ -110,3 +110,22 @@ def test_format_custom_text_mapping_is_per_instance() -> None: assert b.mapping == {} assert a.mapping is not b.mapping assert progressbar.FormatCustomText.mapping == class_default + + +def test_format_custom_text_subclass_keeps_class_default_mapping() -> None: + # A subclass may declare a class-level mapping default; instances + # constructed without an explicit mapping must inherit it (per + # instance, without aliasing the class dict). + class Defaulted(progressbar.FormatCustomText): + # The mutable class attribute is the point of this test: it mirrors + # how third-party subclasses declare default mappings. + mapping = {'spam': 'ham'} # noqa: RUF012 + + widget = Defaulted('%(spam)s') + assert widget.mapping == {'spam': 'ham'} + + widget.update_mapping(spam='eggs') + assert widget.mapping == {'spam': 'eggs'} + # The class default stays untouched by instance mutation. + assert Defaulted.mapping == {'spam': 'ham'} + assert Defaulted('%(spam)s').mapping == {'spam': 'ham'} From 6d9f6dd52f9d45ce3f9832c714290587c2640e2e Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 02:20:03 +0200 Subject: [PATCH 23/24] fix(multi): stop the render thread when join_timeout elapses join(timeout) returning with the thread alive left the daemon looping and writing until interpreter exit; signal it to stop instead. --- progressbar/multi.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/progressbar/multi.py b/progressbar/multi.py index 5471df89..254c132a 100644 --- a/progressbar/multi.py +++ b/progressbar/multi.py @@ -435,6 +435,11 @@ def __exit__( # Bound the wait so a never-finishing bar cannot hang a clean # exit; `join_timeout=None` keeps the historical forever-wait. self.join(timeout=self.join_timeout) + if self._thread is not None: + # The timeout elapsed with bars unfinished: signal the + # render thread to shut down instead of leaving the daemon + # looping (and writing) until interpreter exit. + self.stop(timeout=self.update_interval) else: # Don't wait for unfinished progressbars when an exception is # propagating; that would block forever From 3685440ccfc2e674eae8a409673f22c35f75c655 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 6 Jul 2026 02:28:58 +0200 Subject: [PATCH 24/24] test(multi): start the stuck bar before the render thread exists The join_timeout test started its bar inside the multibar context, so a render tick could catch the bar between _started=True and widget population, crash the render thread on the empty-widgets assert, and let join() succeed on a dead thread -- skipping the timeout path the test exists to exercise (seen as a reproducible 99.93% coverage failure on the py312 CI job). Start the bar fully up front; verified stable across 10 consecutive runs and at 100% coverage on 3.12/3.14. --- tests/test_multibar.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_multibar.py b/tests/test_multibar.py index 1326c04d..e960ebad 100644 --- a/tests/test_multibar.py +++ b/tests/test_multibar.py @@ -283,11 +283,17 @@ def test_multibar_join_timeout_abandons_unfinished_bar() -> None: multibar = progressbar.MultiBar(fd=io.StringIO(), join_timeout=0.1) bar = progressbar.ProgressBar(max_value=10) multibar['stuck'] = bar + # Fully start the bar before the render thread exists: started() flips + # true before the widgets are populated, so a render tick during a + # concurrent bar.start() can crash the render thread on the + # empty-widgets assert -- join() then succeeds on a dead thread and the + # timeout path this test exists to exercise is never taken. + bar.start() + bar.update(5) # never reaches max_value / finish() def exit_context() -> None: with multibar: - bar.start() - bar.update(5) # never reaches max_value / finish() + pass thread = threading.Thread(target=exit_context, daemon=True) thread.start()