diff --git a/progressbar/bar.py b/progressbar/bar.py index 3465ba98..38b224bd 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 @@ -1184,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/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/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/progressbar/multi.py b/progressbar/multi.py index 5eda7eb2..254c132a 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,14 @@ 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) + 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 diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index 2d299f4e..8a79fbce 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 @@ -405,14 +407,20 @@ 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 + # 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 @@ -630,6 +638,18 @@ def __init__(self, color: Color, start_code: int, end_code: int) -> None: self._color = color super().__init__(start_code, end_code) + def __call__( # pyright: ignore[reportIncompatibleMethodOverride] + 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/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/progressbar/widgets.py b/progressbar/widgets.py index 4b9b6c5a..c035f463 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) @@ -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, ) @@ -1174,11 +1185,16 @@ 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). 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) - def update_mapping(self, **mapping: types.Dict[str, types.Any]): + def update_mapping(self, **mapping: types.Any): self.mapping.update(mapping) def __call__( @@ -1245,7 +1261,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) @@ -1254,7 +1271,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 @@ -1566,6 +1586,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, @@ -1584,7 +1611,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) @@ -1604,6 +1637,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, @@ -1636,16 +1674,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/api_surface_snapshot.json b/tests/api_surface_snapshot.json new file mode 100644 index 00000000..e0b19d31 --- /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=?, *, 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)", + "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": "enum(CREATED,LABEL,VALUE,PERCENTAGE)", + "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": "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": "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": "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": "type-alias", + "ValueT": "type-alias", + "annotations": "_Feature", + "datetime": "re-export", + "deepcopy": "callable(x, memo=?, _nil=?)", + "logger": "Logger" + }, + "progressbar.base": { + "FalseMeta": "classsignature-unavailable", + "IO": "re-export", + "TextIO": "re-export", + "Undefined": "class()", + "UnknownLength": "class()" + }, + "progressbar.env": { + "ANSI_TERMS": "tuple", + "ANSI_TERM_RE": "Pattern", + "COLOR_SUPPORT": "ColorSupport", + "ColorSupport": "enum(NONE,XTERM,XTERM_256,XTERM_TRUECOLOR,WINDOWS)", + "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": "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": "enum(CREATED,LABEL,VALUE,PERCENTAGE)", + "SortKeyFunc": "type-alias", + "annotations": "_Feature", + "timedelta": "re-export" + }, + "progressbar.shortcuts": { + "T": "type-alias", + "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": "type-alias", + "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": "re-export", + "Iterator": "re-export", + "LEFT": "callable(*args)", + "LastLineStream": "class(stream)", + "LineOffsetStreamWrapper": "class(lines=?, stream=?)", + "NEXT_LINE": "callable(*args)", + "OptionalColor": "type-alias", + "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": "re-export", + "UP": "callable(*args)", + "WindowsColor": "class(color)", + "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": "re-export", + "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": "type-alias", + "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": "type-alias", + "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": "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": "re-export", + "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": "re-export", + "Iterator": "re-export", + "LastLineStream": "class(stream)", + "LineOffsetStreamWrapper": "class(lines=?, stream=?)", + "TextIOOutputWrapper": "class(stream)", + "TracebackType": "re-export", + "annotations": "_Feature" + }, + "progressbar.utils": { + "@py_assert1": "NoneType", + "@py_assert2": "NoneType", + "AttributeDict": "classsignature-unavailable", + "Iterable": "re-export", + "Iterator": "re-export", + "StreamWrapper": "class()", + "StringT": "type-alias", + "TracebackType": "re-export", + "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": "type-alias", + "ColoredMixin": "class()", + "Counter": "class(format=?, **kwargs)", + "CurrentTime": "class(format=?, microseconds=?, **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)", + "FileTransferSpeed": "class(format=?, inverse_format=?, unit=?, prefixes=?, **kwargs)", + "FormatCustomText": "class(format, mapping=?, **kwargs)", + "FormatLabel": "class(format, **kwargs)", + "FormatLabelBar": "class(format, **kwargs)", + "FormatString": "type-alias", + "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": "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)", + "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..24bd27b8 --- /dev/null +++ b/tests/test_api_surface.py @@ -0,0 +1,170 @@ +"""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 enum +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: + # 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)}' + 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_color.py b/tests/test_color.py index 3c0f5fb4..b3b77421 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: @@ -276,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', [ @@ -408,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' @@ -430,3 +480,69 @@ 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 + + +def test_sgr_color_without_ansi_leaves_text_unstyled(monkeypatch) -> None: + # 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) + 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' diff --git a/tests/test_custom_widgets.py b/tests/test_custom_widgets.py index b0a272e4..ce9f24ca 100644 --- a/tests/test_custom_widgets.py +++ b/tests/test_custom_widgets.py @@ -93,3 +93,39 @@ 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 + + +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'} 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_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.""" 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%. 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) diff --git a/tests/test_multibar.py b/tests/test_multibar.py index daf55a17..e960ebad 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) @@ -257,6 +277,37 @@ 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. + 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: + pass + + thread = threading.Thread(target=exit_context, daemon=True) + thread.start() + thread.join(timeout=5) + 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: # Regression: D1 - thread primitives were class attributes shared # between all MultiBar instances. diff --git a/tests/test_needs_update.py b/tests/test_needs_update.py new file mode 100644 index 00000000..5577d86a --- /dev/null +++ b/tests/test_needs_update.py @@ -0,0 +1,93 @@ +"""`_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 +import progressbar.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. + streams = progressbar.utils.streams + before = set(streams.listeners) + yield + for bar in list(streams.listeners - before): + streams.stop_capturing(bar) + + +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() diff --git a/tests/test_subclass_compat.py b/tests/test_subclass_compat.py new file mode 100644 index 00000000..684ce861 --- /dev/null +++ b/tests/test_subclass_compat.py @@ -0,0 +1,313 @@ +"""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 +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( + 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' diff --git a/tests/test_update_guard.py b/tests/test_update_guard.py new file mode 100644 index 00000000..48457ac1 --- /dev/null +++ b/tests/test_update_guard.py @@ -0,0 +1,48 @@ +"""`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 +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: + 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() 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) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index af4f1a33..f31bc7c9 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -16,13 +16,63 @@ 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_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: ',