diff --git a/docs/major-version-backlog.md b/docs/major-version-backlog.md new file mode 100644 index 00000000..5c5deb9b --- /dev/null +++ b/docs/major-version-backlog.md @@ -0,0 +1,107 @@ +# Major-version backlog + +Breaking changes that the quality audit identified as worth making but that +are **intentionally deferred** to the next major version, because fixing them +now would change the public API or long-standing rendered/CLI behaviour that +users may depend on. Each item is kept working as-is in the current release +line; this document records *why* it is deferred and *where* the code lives so +the change is easy to pick up later. + +Nothing here is a bug that affects correctness of the documented API today — +those were fixed in the audit PRs. These are design debts whose fix is a +compatibility break. + +## MultiBar composes over `dict` instead of subclassing it + +`MultiBar` subclasses `dict` (`progressbar/multi.py`, +`class MultiBar(dict[str, bar.ProgressBar])`). The subclassing leaks several +surprising behaviours: an auto-vivifying `__getitem__` that creates a bar on +missing-key access, state that is dual-keyed by both label and bar object, and +`print` redirection installed by monkeypatching (`bar.print` / `self.print`). +Composition over inheritance (holding a private mapping and exposing an +explicit, documented interface) would remove the auto-vivification foot-gun and +clarify the threading/rendering API, but it changes the type's identity and the +mapping methods callers can use, so it must wait for a major version. + +## `FormatLabelBar.__call__` diamond dispatch and incompatible override + +`FormatLabelBar` (`progressbar/widgets.py`) manually dispatches to both parents +via `FormatLabel.__call__(self, ...)` and `Bar.__call__(self, ...)`, and its +`__call__` override carries a `# type: ignore` because its signature is not +compatible with the base `WidgetBase.__call__` contract. A CodeQL alert on the +incompatible override was dismissed as by-design: the diamond is deliberate and +the widths line up at runtime. Making the signatures genuinely compatible (or +folding the composition into a cooperative call chain) is a public-signature +change, so it is deferred. + +## CLI no-op `pv`-compatibility flags + +`progressbar/__main__.py` declares a `pv(1)`-compatible flag set, but only a +few (`--buffer-size`, `--eta`, `--input`/positional, `--line-mode`, `--size`) +actually feed the runtime. The rest — `--timer`, `--rate`, `--numeric`, +`--delay-start`, `--interval`, `--height`, `--width`, and friends — are parsed +and then silently ignored. They exist so `pv`-style command lines do not error +out. The next major version should either wire each flag to real behaviour or +reject unsupported flags explicitly; both are user-visible CLI changes, so they +are deferred. + +## `__next__` / `next` manual-iteration path + +`ProgressBar.__next__` (`progressbar/bar.py`) advances the bar on manual +iteration but bypasses the fast redraw gate that `update()` goes through, so +hand-rolled `next(bar)` loops render on a different schedule than the normal +paths. `next = __next__` (same file) is a Python-2-era alias kept so old code +calling `bar.next()` keeps working. Routing `__next__` through the gate and +dropping the `next` alias are behaviour/API changes, so they are deferred. + +## `ColorBase` and `WindowsColor` no-op public classes + +`ColorBase` (`progressbar/terminal/base.py`) is an abstract base that its own +docstring describes as deprecated (it only exists because `typing.NamedTuple` +cannot be used as a base for the real `Color`). `WindowsColor` (same file) is +effectively a no-op that duplicates `DummyColor`: recent Windows terminals +support ANSI, so it passes text through unstyled. Both are importable public +names, so removing them is a compatibility break for the next major version. + +## Unused `Colors` lookup indexes + +`Colors.register` (`progressbar/terminal/base.py`) populates four reverse +indexes on every registration — `by_name`, `by_lowername`, `by_hex`, and +`by_hls` — but nothing in the tree ever reads them. For the 256-colour table +that is 256×4 list appends plus the backing dicts at import time, for lookups +no code performs. Dropping the unused indexes would cut import work and memory, +but they are public class attributes, so their removal is deferred. + +## 16-colour terminals receive the `38;5;N` SGR form + +For a detected 16-colour terminal (`env.ColorSupport.XTERM`), `Color.ansi` +(`progressbar/terminal/base.py`) still emits the indexed `38;5;N` / `48;5;N` +256-colour SGR form (with `N` derived from the 16-colour palette) rather than +the canonical `30`–`37` / `90`–`97` direct codes. This is a pre-existing +convention that real terminals tolerate; switching to the direct codes changes +the exact bytes emitted for every colour on those terminals, so it is deferred. + +## Deferred `DeprecationWarning` upgrades (silent-compat policy) + +Several backwards-compatibility shims currently accept legacy usage silently +rather than warning: + +- `apply_colors(**kwargs)` (`progressbar/terminal/base.py`) swallows unknown + keyword arguments instead of rejecting them. +- Legacy aliases such as `RotatingMarker` (for `AnimatedMarker`) and + `DynamicMessage` (for `Variable`) in `progressbar/widgets.py`. +- The `%s` → `%(elapsed)s` / `%(eta)s` format shims in `Timer` / `ETA` + (`progressbar/widgets.py`). + +Emitting `DeprecationWarning` from these paths is the right long-term move, but +warnings are observable behaviour (and can break `-W error` test suites), so +the upgrade is a coordinated major-version change. + +## `deltas_to_seconds` sentinel/`ValueError` contract + +`deltas_to_seconds` (`progressbar/utils.py`) is now expressed as typed +overloads, but its runtime contract around the not-a-number / default sentinel +still leans on raising `ValueError` for a class of inputs. A cleaner redesign +(an explicit sentinel type or an `Optional`-returning overload set) would be a +signature/behaviour change for callers that catch `ValueError`, so it is +deferred to the API redesign in the next major version. diff --git a/progressbar/__about__.py b/progressbar/__about__.py index 785fff86..0e870b3f 100644 --- a/progressbar/__about__.py +++ b/progressbar/__about__.py @@ -22,6 +22,8 @@ ) __email__ = 'wolph@wol.ph' __version__ = '4.5.0' +#: Release date of ``__version__`` as a static string; bump on each release. +__date__ = '2024-08-29' __license__ = 'BSD' -__copyright__ = 'Copyright 2015 Rick van Hattem (Wolph)' -__url__ = 'https://github.com/WoLpH/python-progressbar' +__copyright__ = 'Copyright 2015-2026 Rick van Hattem (Wolph)' +__url__ = 'https://github.com/wolph/python-progressbar' diff --git a/progressbar/__init__.py b/progressbar/__init__.py index 3c998a9b..7f0e04fc 100644 --- a/progressbar/__init__.py +++ b/progressbar/__init__.py @@ -8,9 +8,15 @@ import importlib import typing -from datetime import date -from .__about__ import __author__, __version__ +# Re-exported as a static module attribute (kept out of ``__all__`` for +# backwards compatibility). ``__date__`` used to be recomputed at import time +# via ``date.today()``; it now mirrors the release date from ``__about__``. +from .__about__ import ( + __author__, + __date__ as __date__, + __version__, +) if typing.TYPE_CHECKING: # Eager imports for type checkers only; loaded lazily at runtime by @@ -140,7 +146,6 @@ def __dir__() -> list[str]: return sorted(set(globals()) | set(__all__) | _SUBMODULES) -__date__ = str(date.today()) #: Canonical export list, kept equal to ``sorted(_NAME_TO_MODULE)`` (the single #: source of truth for lazily re-exported names) plus the eagerly imported #: dunders. Held as a static literal so type checkers can see the re-exports; diff --git a/progressbar/bar.py b/progressbar/bar.py index ed35a724..4643b76f 100644 --- a/progressbar/bar.py +++ b/progressbar/bar.py @@ -93,10 +93,9 @@ class ProgressBarMixinBase(abc.ABC): #: The default keyword arguments for the `default_widgets` if no widgets #: are configured widget_kwargs: dict[str, typing.Any] - #: Custom length function for multibyte characters such as CJK - # mypy and pyright can't agree on what the correct one is... so we'll - # need to use a helper function :( - # custom_len: types.Callable[['ProgressBarMixinBase', str], int] + #: Custom length function for multibyte characters such as CJK. A plain + #: ``Callable[[str], int]`` is used (rather than a bound-method signature) + #: because mypy and pyright disagree on the more precise form. custom_len: collections.abc.Callable[[str], int] #: The time the progress bar was started initial_start_time: datetime | None @@ -207,6 +206,7 @@ def __repr__(self): label = f': {self.label}' if self.label else '' return f'<{self.__class__.__name__}#{self.index}{label}>' + class DefaultFdMixin(ProgressBarMixinBase): # The file descriptor to write to. Defaults to `sys.stderr` fd: base.TextIO = sys.stderr @@ -933,8 +933,6 @@ def data(self) -> dict[str, typing.Any]: :py:meth:`_mark_update` before the widgets read them. """ elapsed = self.last_update_time - self.start_time # type: ignore - # For Python 2.7 and higher we have _`timedelta.total_seconds`, but we - # want to support older versions as well total_seconds_elapsed = utils.deltas_to_seconds(elapsed) return dict( # The maximum value (can be None with iterators) diff --git a/progressbar/env.py b/progressbar/env.py index ef6514c9..be4f47da 100644 --- a/progressbar/env.py +++ b/progressbar/env.py @@ -195,12 +195,6 @@ def is_terminal( return is_terminal -# Enable Windows full color mode if possible -if os.name == 'nt': - pass - - # os_specific.set_console_mode() - JUPYTER = bool( os.environ.get('JUPYTER_COLUMNS') or os.environ.get('JUPYTER_LINES') diff --git a/progressbar/terminal/base.py b/progressbar/terminal/base.py index 88eb51d5..796c5f6d 100644 --- a/progressbar/terminal/base.py +++ b/progressbar/terminal/base.py @@ -4,7 +4,6 @@ import collections.abc import colorsys import enum -import threading import typing from collections import defaultdict @@ -18,7 +17,11 @@ base as pbase, env, ) -from .os_specific import getch + +# Re-exported for backwards compatibility (previously consumed by the removed +# ``_CPR`` cursor-position helper; guarded by the API snapshot). The redundant +# alias marks the re-export as intentional so it is not stripped as unused. +from .os_specific import getch as getch ESC = '\x1b' @@ -145,43 +148,6 @@ def clear_line(n: int): return UP(n) + CLEAR_LINE_ALL() + DOWN(n) -# Report Cursor Position (CPR), response = [row;column] as row;columnR -class _CPR(str): # pragma: no cover # pyright: ignore[reportUnusedClass] - _response_lock = threading.Lock() - - def __call__(self, stream: typing.IO[str]) -> tuple[int, int]: - res: str = '' - - with self._response_lock: - stream.write(str(self)) - stream.flush() - - while not res.endswith('R'): - char = getch() - - if char: - res += char - - res_list = res[2:-1].split(';') - - res_list = tuple( - int(item) if item.isdigit() else item for item in res_list - ) - - if len(res_list) == 1: - return typing.cast(tuple[int, int], res_list[0]) - - return typing.cast(tuple[int, int], tuple(res_list)) - - def row(self, stream: typing.IO[str]) -> int: - row, _ = self(stream) - return row - - def column(self, stream: typing.IO[str]) -> int: - _, column = self(stream) - return column - - class WindowsColors(enum.Enum): BLACK = 0, 0, 0 BLUE = 0, 0, 128 diff --git a/progressbar/terminal/colors.py b/progressbar/terminal/colors.py index 37e5ea90..1da55c8a 100644 --- a/progressbar/terminal/colors.py +++ b/progressbar/terminal/colors.py @@ -22,46 +22,46 @@ aqua = Colors.register(RGB(0, 255, 255), HSL(180, 100, 50), 'Aqua', 14) white = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'White', 15) grey0 = Colors.register(RGB(0, 0, 0), HSL(0, 0, 0), 'Grey0', 16) -navy_blue = Colors.register(RGB(0, 0, 95), HSL(240, 100, 18), 'NavyBlue', 17) +navy_blue = Colors.register(RGB(0, 0, 95), HSL(240, 100, 19), 'NavyBlue', 17) dark_blue = Colors.register(RGB(0, 0, 135), HSL(240, 100, 26), 'DarkBlue', 18) blue3 = Colors.register(RGB(0, 0, 175), HSL(240, 100, 34), 'Blue3', 19) blue3 = Colors.register(RGB(0, 0, 215), HSL(240, 100, 42), 'Blue3', 20) blue1 = Colors.register(RGB(0, 0, 255), HSL(240, 100, 50), 'Blue1', 21) -dark_green = Colors.register(RGB(0, 95, 0), HSL(120, 100, 18), 'DarkGreen', 22) +dark_green = Colors.register(RGB(0, 95, 0), HSL(120, 100, 19), 'DarkGreen', 22) deep_sky_blue4 = Colors.register( RGB(0, 95, 95), - HSL(180, 100, 18), + HSL(180, 100, 19), 'DeepSkyBlue4', 23, ) deep_sky_blue4 = Colors.register( RGB(0, 95, 135), - HSL(97, 100, 26), + HSL(198, 100, 26), 'DeepSkyBlue4', 24, ) deep_sky_blue4 = Colors.register( RGB(0, 95, 175), - HSL(7, 100, 34), + HSL(207, 100, 34), 'DeepSkyBlue4', 25, ) dodger_blue3 = Colors.register( RGB(0, 95, 215), - HSL(13, 100, 42), + HSL(213, 100, 42), 'DodgerBlue3', 26, ) dodger_blue2 = Colors.register( RGB(0, 95, 255), - HSL(17, 100, 50), + HSL(218, 100, 50), 'DodgerBlue2', 27, ) green4 = Colors.register(RGB(0, 135, 0), HSL(120, 100, 26), 'Green4', 28) spring_green4 = Colors.register( RGB(0, 135, 95), - HSL(62, 100, 26), + HSL(162, 100, 26), 'SpringGreen4', 29, ) @@ -73,30 +73,35 @@ ) deep_sky_blue3 = Colors.register( RGB(0, 135, 175), - HSL(93, 100, 34), + HSL(194, 100, 34), 'DeepSkyBlue3', 31, ) deep_sky_blue3 = Colors.register( RGB(0, 135, 215), - HSL(2, 100, 42), + HSL(202, 100, 42), 'DeepSkyBlue3', 32, ) dodger_blue1 = Colors.register( RGB(0, 135, 255), - HSL(8, 100, 50), + HSL(208, 100, 50), 'DodgerBlue1', 33, ) green3 = Colors.register(RGB(0, 175, 0), HSL(120, 100, 34), 'Green3', 34) spring_green3 = Colors.register( RGB(0, 175, 95), - HSL(52, 100, 34), + HSL(153, 100, 34), 'SpringGreen3', 35, ) -dark_cyan = Colors.register(RGB(0, 175, 135), HSL(66, 100, 34), 'DarkCyan', 36) +dark_cyan = Colors.register( + RGB(0, 175, 135), + HSL(166, 100, 34), + 'DarkCyan', + 36, +) light_sea_green = Colors.register( RGB(0, 175, 175), HSL(180, 100, 34), @@ -105,30 +110,30 @@ ) deep_sky_blue2 = Colors.register( RGB(0, 175, 215), - HSL(91, 100, 42), + HSL(191, 100, 42), 'DeepSkyBlue2', 38, ) deep_sky_blue1 = Colors.register( RGB(0, 175, 255), - HSL(98, 100, 50), + HSL(199, 100, 50), 'DeepSkyBlue1', 39, ) green3 = Colors.register(RGB(0, 215, 0), HSL(120, 100, 42), 'Green3', 40) spring_green3 = Colors.register( RGB(0, 215, 95), - HSL(46, 100, 42), + HSL(147, 100, 42), 'SpringGreen3', 41, ) spring_green2 = Colors.register( RGB(0, 215, 135), - HSL(57, 100, 42), + HSL(158, 100, 42), 'SpringGreen2', 42, ) -cyan3 = Colors.register(RGB(0, 215, 175), HSL(68, 100, 42), 'Cyan3', 43) +cyan3 = Colors.register(RGB(0, 215, 175), HSL(169, 100, 42), 'Cyan3', 43) dark_turquoise = Colors.register( RGB(0, 215, 215), HSL(180, 100, 42), @@ -137,48 +142,48 @@ ) turquoise2 = Colors.register( RGB(0, 215, 255), - HSL(89, 100, 50), + HSL(189, 100, 50), 'Turquoise2', 45, ) green1 = Colors.register(RGB(0, 255, 0), HSL(120, 100, 50), 'Green1', 46) spring_green2 = Colors.register( RGB(0, 255, 95), - HSL(42, 100, 50), + HSL(142, 100, 50), 'SpringGreen2', 47, ) spring_green1 = Colors.register( RGB(0, 255, 135), - HSL(51, 100, 50), + HSL(152, 100, 50), 'SpringGreen1', 48, ) medium_spring_green = Colors.register( RGB(0, 255, 175), - HSL(61, 100, 50), + HSL(161, 100, 50), 'MediumSpringGreen', 49, ) -cyan2 = Colors.register(RGB(0, 255, 215), HSL(70, 100, 50), 'Cyan2', 50) +cyan2 = Colors.register(RGB(0, 255, 215), HSL(171, 100, 50), 'Cyan2', 50) cyan1 = Colors.register(RGB(0, 255, 255), HSL(180, 100, 50), 'Cyan1', 51) -dark_red = Colors.register(RGB(95, 0, 0), HSL(0, 100, 18), 'DarkRed', 52) +dark_red = Colors.register(RGB(95, 0, 0), HSL(0, 100, 19), 'DarkRed', 52) deep_pink4 = Colors.register( RGB(95, 0, 95), - HSL(300, 100, 18), + HSL(300, 100, 19), 'DeepPink4', 53, ) -purple4 = Colors.register(RGB(95, 0, 135), HSL(82, 100, 26), 'Purple4', 54) -purple4 = Colors.register(RGB(95, 0, 175), HSL(72, 100, 34), 'Purple4', 55) -purple3 = Colors.register(RGB(95, 0, 215), HSL(66, 100, 42), 'Purple3', 56) +purple4 = Colors.register(RGB(95, 0, 135), HSL(282, 100, 26), 'Purple4', 54) +purple4 = Colors.register(RGB(95, 0, 175), HSL(273, 100, 34), 'Purple4', 55) +purple3 = Colors.register(RGB(95, 0, 215), HSL(267, 100, 42), 'Purple3', 56) blue_violet = Colors.register( RGB(95, 0, 255), - HSL(62, 100, 50), + HSL(262, 100, 50), 'BlueViolet', 57, ) -orange4 = Colors.register(RGB(95, 95, 0), HSL(60, 100, 18), 'Orange4', 58) +orange4 = Colors.register(RGB(95, 95, 0), HSL(60, 100, 19), 'Orange4', 58) grey37 = Colors.register(RGB(95, 95, 95), HSL(0, 0, 37), 'Grey37', 59) medium_purple4 = Colors.register( RGB(95, 95, 135), @@ -188,25 +193,25 @@ ) slate_blue3 = Colors.register( RGB(95, 95, 175), - HSL(240, 33, 52), + HSL(240, 33, 53), 'SlateBlue3', 61, ) slate_blue3 = Colors.register( RGB(95, 95, 215), - HSL(240, 60, 60), + HSL(240, 60, 61), 'SlateBlue3', 62, ) royal_blue1 = Colors.register( RGB(95, 95, 255), - HSL(240, 100, 68), + HSL(240, 100, 69), 'RoyalBlue1', 63, ) chartreuse4 = Colors.register( RGB(95, 135, 0), - HSL(7, 100, 26), + HSL(78, 100, 26), 'Chartreuse4', 64, ) @@ -224,134 +229,134 @@ ) steel_blue = Colors.register( RGB(95, 135, 175), - HSL(210, 33, 52), + HSL(210, 33, 53), 'SteelBlue', 67, ) steel_blue3 = Colors.register( RGB(95, 135, 215), - HSL(220, 60, 60), + HSL(220, 60, 61), 'SteelBlue3', 68, ) cornflower_blue = Colors.register( RGB(95, 135, 255), - HSL(225, 100, 68), + HSL(225, 100, 69), 'CornflowerBlue', 69, ) chartreuse3 = Colors.register( RGB(95, 175, 0), - HSL(7, 100, 34), + HSL(87, 100, 34), 'Chartreuse3', 70, ) dark_sea_green4 = Colors.register( RGB(95, 175, 95), - HSL(120, 33, 52), + HSL(120, 33, 53), 'DarkSeaGreen4', 71, ) cadet_blue = Colors.register( RGB(95, 175, 135), - HSL(150, 33, 52), + HSL(150, 33, 53), 'CadetBlue', 72, ) cadet_blue = Colors.register( RGB(95, 175, 175), - HSL(180, 33, 52), + HSL(180, 33, 53), 'CadetBlue', 73, ) sky_blue3 = Colors.register( RGB(95, 175, 215), - HSL(200, 60, 60), + HSL(200, 60, 61), 'SkyBlue3', 74, ) steel_blue1 = Colors.register( RGB(95, 175, 255), - HSL(210, 100, 68), + HSL(210, 100, 69), 'SteelBlue1', 75, ) chartreuse3 = Colors.register( RGB(95, 215, 0), - HSL(3, 100, 42), + HSL(93, 100, 42), 'Chartreuse3', 76, ) pale_green3 = Colors.register( RGB(95, 215, 95), - HSL(120, 60, 60), + HSL(120, 60, 61), 'PaleGreen3', 77, ) sea_green3 = Colors.register( RGB(95, 215, 135), - HSL(140, 60, 60), + HSL(140, 60, 61), 'SeaGreen3', 78, ) aquamarine3 = Colors.register( RGB(95, 215, 175), - HSL(160, 60, 60), + HSL(160, 60, 61), 'Aquamarine3', 79, ) medium_turquoise = Colors.register( RGB(95, 215, 215), - HSL(180, 60, 60), + HSL(180, 60, 61), 'MediumTurquoise', 80, ) steel_blue1 = Colors.register( RGB(95, 215, 255), - HSL(195, 100, 68), + HSL(195, 100, 69), 'SteelBlue1', 81, ) chartreuse2 = Colors.register( RGB(95, 255, 0), - HSL(7, 100, 50), + HSL(98, 100, 50), 'Chartreuse2', 82, ) sea_green2 = Colors.register( RGB(95, 255, 95), - HSL(120, 100, 68), + HSL(120, 100, 69), 'SeaGreen2', 83, ) sea_green1 = Colors.register( RGB(95, 255, 135), - HSL(135, 100, 68), + HSL(135, 100, 69), 'SeaGreen1', 84, ) sea_green1 = Colors.register( RGB(95, 255, 175), - HSL(150, 100, 68), + HSL(150, 100, 69), 'SeaGreen1', 85, ) aquamarine1 = Colors.register( RGB(95, 255, 215), - HSL(165, 100, 68), + HSL(165, 100, 69), 'Aquamarine1', 86, ) dark_slate_gray2 = Colors.register( RGB(95, 255, 255), - HSL(180, 100, 68), + HSL(180, 100, 69), 'DarkSlateGray2', 87, ) dark_red = Colors.register(RGB(135, 0, 0), HSL(0, 100, 26), 'DarkRed', 88) deep_pink4 = Colors.register( RGB(135, 0, 95), - HSL(17, 100, 26), + HSL(318, 100, 26), 'DeepPink4', 89, ) @@ -363,18 +368,18 @@ ) dark_magenta = Colors.register( RGB(135, 0, 175), - HSL(86, 100, 34), + HSL(286, 100, 34), 'DarkMagenta', 91, ) dark_violet = Colors.register( RGB(135, 0, 215), - HSL(77, 100, 42), + HSL(278, 100, 42), 'DarkViolet', 92, ) -purple = Colors.register(RGB(135, 0, 255), HSL(71, 100, 50), 'Purple', 93) -orange4 = Colors.register(RGB(135, 95, 0), HSL(2, 100, 26), 'Orange4', 94) +purple = Colors.register(RGB(135, 0, 255), HSL(272, 100, 50), 'Purple', 93) +orange4 = Colors.register(RGB(135, 95, 0), HSL(42, 100, 26), 'Orange4', 94) light_pink4 = Colors.register( RGB(135, 95, 95), HSL(0, 17, 45), @@ -384,34 +389,34 @@ plum4 = Colors.register(RGB(135, 95, 135), HSL(300, 17, 45), 'Plum4', 96) medium_purple3 = Colors.register( RGB(135, 95, 175), - HSL(270, 33, 52), + HSL(270, 33, 53), 'MediumPurple3', 97, ) medium_purple3 = Colors.register( RGB(135, 95, 215), - HSL(260, 60, 60), + HSL(260, 60, 61), 'MediumPurple3', 98, ) slate_blue1 = Colors.register( RGB(135, 95, 255), - HSL(255, 100, 68), + HSL(255, 100, 69), 'SlateBlue1', 99, ) yellow4 = Colors.register(RGB(135, 135, 0), HSL(60, 100, 26), 'Yellow4', 100) wheat4 = Colors.register(RGB(135, 135, 95), HSL(60, 17, 45), 'Wheat4', 101) -grey53 = Colors.register(RGB(135, 135, 135), HSL(0, 0, 52), 'Grey53', 102) +grey53 = Colors.register(RGB(135, 135, 135), HSL(0, 0, 53), 'Grey53', 102) light_slate_grey = Colors.register( RGB(135, 135, 175), - HSL(240, 20, 60), + HSL(240, 20, 61), 'LightSlateGrey', 103, ) medium_purple = Colors.register( RGB(135, 135, 215), - HSL(240, 50, 68), + HSL(240, 50, 69), 'MediumPurple', 104, ) @@ -421,28 +426,28 @@ 'LightSlateBlue', 105, ) -yellow4 = Colors.register(RGB(135, 175, 0), HSL(3, 100, 34), 'Yellow4', 106) +yellow4 = Colors.register(RGB(135, 175, 0), HSL(74, 100, 34), 'Yellow4', 106) dark_olive_green3 = Colors.register( RGB(135, 175, 95), - HSL(90, 33, 52), + HSL(90, 33, 53), 'DarkOliveGreen3', 107, ) dark_sea_green = Colors.register( RGB(135, 175, 135), - HSL(120, 20, 60), + HSL(120, 20, 61), 'DarkSeaGreen', 108, ) light_sky_blue3 = Colors.register( RGB(135, 175, 175), - HSL(180, 20, 60), + HSL(180, 20, 61), 'LightSkyBlue3', 109, ) light_sky_blue3 = Colors.register( RGB(135, 175, 215), - HSL(210, 50, 68), + HSL(210, 50, 69), 'LightSkyBlue3', 110, ) @@ -454,31 +459,31 @@ ) chartreuse2 = Colors.register( RGB(135, 215, 0), - HSL(2, 100, 42), + HSL(82, 100, 42), 'Chartreuse2', 112, ) dark_olive_green3 = Colors.register( RGB(135, 215, 95), - HSL(100, 60, 60), + HSL(100, 60, 61), 'DarkOliveGreen3', 113, ) pale_green3 = Colors.register( RGB(135, 215, 135), - HSL(120, 50, 68), + HSL(120, 50, 69), 'PaleGreen3', 114, ) dark_sea_green3 = Colors.register( RGB(135, 215, 175), - HSL(150, 50, 68), + HSL(150, 50, 69), 'DarkSeaGreen3', 115, ) dark_slate_gray3 = Colors.register( RGB(135, 215, 215), - HSL(180, 50, 68), + HSL(180, 50, 69), 'DarkSlateGray3', 116, ) @@ -490,13 +495,13 @@ ) chartreuse1 = Colors.register( RGB(135, 255, 0), - HSL(8, 100, 50), + HSL(88, 100, 50), 'Chartreuse1', 118, ) light_green = Colors.register( RGB(135, 255, 95), - HSL(105, 100, 68), + HSL(105, 100, 69), 'LightGreen', 119, ) @@ -527,13 +532,13 @@ red3 = Colors.register(RGB(175, 0, 0), HSL(0, 100, 34), 'Red3', 124) deep_pink4 = Colors.register( RGB(175, 0, 95), - HSL(27, 100, 34), + HSL(327, 100, 34), 'DeepPink4', 125, ) medium_violet_red = Colors.register( RGB(175, 0, 135), - HSL(13, 100, 34), + HSL(314, 100, 34), 'MediumVioletRed', 126, ) @@ -545,69 +550,69 @@ ) dark_violet = Colors.register( RGB(175, 0, 215), - HSL(88, 100, 42), + HSL(289, 100, 42), 'DarkViolet', 128, ) -purple = Colors.register(RGB(175, 0, 255), HSL(81, 100, 50), 'Purple', 129) +purple = Colors.register(RGB(175, 0, 255), HSL(281, 100, 50), 'Purple', 129) dark_orange3 = Colors.register( RGB(175, 95, 0), - HSL(2, 100, 34), + HSL(33, 100, 34), 'DarkOrange3', 130, ) indian_red = Colors.register( RGB(175, 95, 95), - HSL(0, 33, 52), + HSL(0, 33, 53), 'IndianRed', 131, ) hot_pink3 = Colors.register( RGB(175, 95, 135), - HSL(330, 33, 52), + HSL(330, 33, 53), 'HotPink3', 132, ) medium_orchid3 = Colors.register( RGB(175, 95, 175), - HSL(300, 33, 52), + HSL(300, 33, 53), 'MediumOrchid3', 133, ) medium_orchid = Colors.register( RGB(175, 95, 215), - HSL(280, 60, 60), + HSL(280, 60, 61), 'MediumOrchid', 134, ) medium_purple2 = Colors.register( RGB(175, 95, 255), - HSL(270, 100, 68), + HSL(270, 100, 69), 'MediumPurple2', 135, ) dark_goldenrod = Colors.register( RGB(175, 135, 0), - HSL(6, 100, 34), + HSL(46, 100, 34), 'DarkGoldenrod', 136, ) light_salmon3 = Colors.register( RGB(175, 135, 95), - HSL(30, 33, 52), + HSL(30, 33, 53), 'LightSalmon3', 137, ) rosy_brown = Colors.register( RGB(175, 135, 135), - HSL(0, 20, 60), + HSL(0, 20, 61), 'RosyBrown', 138, ) -grey63 = Colors.register(RGB(175, 135, 175), HSL(300, 20, 60), 'Grey63', 139) +grey63 = Colors.register(RGB(175, 135, 175), HSL(300, 20, 61), 'Grey63', 139) medium_purple2 = Colors.register( RGB(175, 135, 215), - HSL(270, 50, 68), + HSL(270, 50, 69), 'MediumPurple2', 140, ) @@ -620,17 +625,17 @@ gold3 = Colors.register(RGB(175, 175, 0), HSL(60, 100, 34), 'Gold3', 142) dark_khaki = Colors.register( RGB(175, 175, 95), - HSL(60, 33, 52), + HSL(60, 33, 53), 'DarkKhaki', 143, ) navajo_white3 = Colors.register( RGB(175, 175, 135), - HSL(60, 20, 60), + HSL(60, 20, 61), 'NavajoWhite3', 144, ) -grey69 = Colors.register(RGB(175, 175, 175), HSL(0, 0, 68), 'Grey69', 145) +grey69 = Colors.register(RGB(175, 175, 175), HSL(0, 0, 69), 'Grey69', 145) light_steel_blue3 = Colors.register( RGB(175, 175, 215), HSL(240, 33, 76), @@ -643,16 +648,16 @@ 'LightSteelBlue', 147, ) -yellow3 = Colors.register(RGB(175, 215, 0), HSL(1, 100, 42), 'Yellow3', 148) +yellow3 = Colors.register(RGB(175, 215, 0), HSL(71, 100, 42), 'Yellow3', 148) dark_olive_green3 = Colors.register( RGB(175, 215, 95), - HSL(80, 60, 60), + HSL(80, 60, 61), 'DarkOliveGreen3', 149, ) dark_sea_green3 = Colors.register( RGB(175, 215, 135), - HSL(90, 50, 68), + HSL(90, 50, 69), 'DarkSeaGreen3', 150, ) @@ -676,13 +681,13 @@ ) green_yellow = Colors.register( RGB(175, 255, 0), - HSL(8, 100, 50), + HSL(79, 100, 50), 'GreenYellow', 154, ) dark_olive_green2 = Colors.register( RGB(175, 255, 95), - HSL(90, 100, 68), + HSL(90, 100, 69), 'DarkOliveGreen2', 155, ) @@ -713,79 +718,89 @@ red3 = Colors.register(RGB(215, 0, 0), HSL(0, 100, 42), 'Red3', 160) deep_pink3 = Colors.register( RGB(215, 0, 95), - HSL(33, 100, 42), + HSL(333, 100, 42), 'DeepPink3', 161, ) deep_pink3 = Colors.register( RGB(215, 0, 135), - HSL(22, 100, 42), + HSL(322, 100, 42), 'DeepPink3', 162, ) -magenta3 = Colors.register(RGB(215, 0, 175), HSL(11, 100, 42), 'Magenta3', 163) +magenta3 = Colors.register( + RGB(215, 0, 175), + HSL(311, 100, 42), + 'Magenta3', + 163, +) magenta3 = Colors.register( RGB(215, 0, 215), HSL(300, 100, 42), 'Magenta3', 164, ) -magenta2 = Colors.register(RGB(215, 0, 255), HSL(90, 100, 50), 'Magenta2', 165) +magenta2 = Colors.register( + RGB(215, 0, 255), + HSL(291, 100, 50), + 'Magenta2', + 165, +) dark_orange3 = Colors.register( RGB(215, 95, 0), - HSL(6, 100, 42), + HSL(27, 100, 42), 'DarkOrange3', 166, ) indian_red = Colors.register( RGB(215, 95, 95), - HSL(0, 60, 60), + HSL(0, 60, 61), 'IndianRed', 167, ) hot_pink3 = Colors.register( RGB(215, 95, 135), - HSL(340, 60, 60), + HSL(340, 60, 61), 'HotPink3', 168, ) hot_pink2 = Colors.register( RGB(215, 95, 175), - HSL(320, 60, 60), + HSL(320, 60, 61), 'HotPink2', 169, ) -orchid = Colors.register(RGB(215, 95, 215), HSL(300, 60, 60), 'Orchid', 170) +orchid = Colors.register(RGB(215, 95, 215), HSL(300, 60, 61), 'Orchid', 170) medium_orchid1 = Colors.register( RGB(215, 95, 255), - HSL(285, 100, 68), + HSL(285, 100, 69), 'MediumOrchid1', 171, ) -orange3 = Colors.register(RGB(215, 135, 0), HSL(7, 100, 42), 'Orange3', 172) +orange3 = Colors.register(RGB(215, 135, 0), HSL(38, 100, 42), 'Orange3', 172) light_salmon3 = Colors.register( RGB(215, 135, 95), - HSL(20, 60, 60), + HSL(20, 60, 61), 'LightSalmon3', 173, ) light_pink3 = Colors.register( RGB(215, 135, 135), - HSL(0, 50, 68), + HSL(0, 50, 69), 'LightPink3', 174, ) -pink3 = Colors.register(RGB(215, 135, 175), HSL(330, 50, 68), 'Pink3', 175) -plum3 = Colors.register(RGB(215, 135, 215), HSL(300, 50, 68), 'Plum3', 176) +pink3 = Colors.register(RGB(215, 135, 175), HSL(330, 50, 69), 'Pink3', 175) +plum3 = Colors.register(RGB(215, 135, 215), HSL(300, 50, 69), 'Plum3', 176) violet = Colors.register(RGB(215, 135, 255), HSL(280, 100, 76), 'Violet', 177) -gold3 = Colors.register(RGB(215, 175, 0), HSL(8, 100, 42), 'Gold3', 178) +gold3 = Colors.register(RGB(215, 175, 0), HSL(49, 100, 42), 'Gold3', 178) light_goldenrod3 = Colors.register( RGB(215, 175, 95), - HSL(40, 60, 60), + HSL(40, 60, 61), 'LightGoldenrod3', 179, ) -tan = Colors.register(RGB(215, 175, 135), HSL(30, 50, 68), 'Tan', 180) +tan = Colors.register(RGB(215, 175, 135), HSL(30, 50, 69), 'Tan', 180) misty_rose3 = Colors.register( RGB(215, 175, 175), HSL(0, 33, 76), @@ -800,10 +815,10 @@ ) plum2 = Colors.register(RGB(215, 175, 255), HSL(270, 100, 84), 'Plum2', 183) yellow3 = Colors.register(RGB(215, 215, 0), HSL(60, 100, 42), 'Yellow3', 184) -khaki3 = Colors.register(RGB(215, 215, 95), HSL(60, 60, 60), 'Khaki3', 185) +khaki3 = Colors.register(RGB(215, 215, 95), HSL(60, 60, 61), 'Khaki3', 185) light_goldenrod2 = Colors.register( RGB(215, 215, 135), - HSL(60, 50, 68), + HSL(60, 50, 69), 'LightGoldenrod2', 186, ) @@ -820,10 +835,10 @@ 'LightSteelBlue1', 189, ) -yellow2 = Colors.register(RGB(215, 255, 0), HSL(9, 100, 50), 'Yellow2', 190) +yellow2 = Colors.register(RGB(215, 255, 0), HSL(69, 100, 50), 'Yellow2', 190) dark_olive_green1 = Colors.register( RGB(215, 255, 95), - HSL(75, 100, 68), + HSL(75, 100, 69), 'DarkOliveGreen1', 191, ) @@ -854,23 +869,28 @@ red1 = Colors.register(RGB(255, 0, 0), HSL(0, 100, 50), 'Red1', 196) deep_pink2 = Colors.register( RGB(255, 0, 95), - HSL(37, 100, 50), + HSL(338, 100, 50), 'DeepPink2', 197, ) deep_pink1 = Colors.register( RGB(255, 0, 135), - HSL(28, 100, 50), + HSL(328, 100, 50), 'DeepPink1', 198, ) deep_pink1 = Colors.register( RGB(255, 0, 175), - HSL(18, 100, 50), + HSL(319, 100, 50), 'DeepPink1', 199, ) -magenta2 = Colors.register(RGB(255, 0, 215), HSL(9, 100, 50), 'Magenta2', 200) +magenta2 = Colors.register( + RGB(255, 0, 215), + HSL(309, 100, 50), + 'Magenta2', + 200, +) magenta1 = Colors.register( RGB(255, 0, 255), HSL(300, 100, 50), @@ -879,47 +899,47 @@ ) orange_red1 = Colors.register( RGB(255, 95, 0), - HSL(2, 100, 50), + HSL(22, 100, 50), 'OrangeRed1', 202, ) indian_red1 = Colors.register( RGB(255, 95, 95), - HSL(0, 100, 68), + HSL(0, 100, 69), 'IndianRed1', 203, ) indian_red1 = Colors.register( RGB(255, 95, 135), - HSL(345, 100, 68), + HSL(345, 100, 69), 'IndianRed1', 204, ) hot_pink = Colors.register( RGB(255, 95, 175), - HSL(330, 100, 68), + HSL(330, 100, 69), 'HotPink', 205, ) hot_pink = Colors.register( RGB(255, 95, 215), - HSL(315, 100, 68), + HSL(315, 100, 69), 'HotPink', 206, ) medium_orchid1 = Colors.register( RGB(255, 95, 255), - HSL(300, 100, 68), + HSL(300, 100, 69), 'MediumOrchid1', 207, ) dark_orange = Colors.register( RGB(255, 135, 0), - HSL(1, 100, 50), + HSL(32, 100, 50), 'DarkOrange', 208, ) -salmon1 = Colors.register(RGB(255, 135, 95), HSL(15, 100, 68), 'Salmon1', 209) +salmon1 = Colors.register(RGB(255, 135, 95), HSL(15, 100, 69), 'Salmon1', 209) light_coral = Colors.register( RGB(255, 135, 135), HSL(0, 100, 76), @@ -944,10 +964,10 @@ 'Orchid1', 213, ) -orange1 = Colors.register(RGB(255, 175, 0), HSL(1, 100, 50), 'Orange1', 214) +orange1 = Colors.register(RGB(255, 175, 0), HSL(41, 100, 50), 'Orange1', 214) sandy_brown = Colors.register( RGB(255, 175, 95), - HSL(30, 100, 68), + HSL(30, 100, 69), 'SandyBrown', 215, ) @@ -965,10 +985,10 @@ ) pink1 = Colors.register(RGB(255, 175, 215), HSL(330, 100, 84), 'Pink1', 218) plum1 = Colors.register(RGB(255, 175, 255), HSL(300, 100, 84), 'Plum1', 219) -gold1 = Colors.register(RGB(255, 215, 0), HSL(0, 100, 50), 'Gold1', 220) +gold1 = Colors.register(RGB(255, 215, 0), HSL(51, 100, 50), 'Gold1', 220) light_goldenrod2 = Colors.register( RGB(255, 215, 95), - HSL(45, 100, 68), + HSL(45, 100, 69), 'LightGoldenrod2', 221, ) @@ -999,7 +1019,7 @@ yellow1 = Colors.register(RGB(255, 255, 0), HSL(60, 100, 50), 'Yellow1', 226) light_goldenrod1 = Colors.register( RGB(255, 255, 95), - HSL(60, 100, 68), + HSL(60, 100, 69), 'LightGoldenrod1', 227, ) @@ -1014,25 +1034,25 @@ grey100 = Colors.register(RGB(255, 255, 255), HSL(0, 0, 100), 'Grey100', 231) grey3 = Colors.register(RGB(8, 8, 8), HSL(0, 0, 3), 'Grey3', 232) grey7 = Colors.register(RGB(18, 18, 18), HSL(0, 0, 7), 'Grey7', 233) -grey11 = Colors.register(RGB(28, 28, 28), HSL(0, 0, 10), 'Grey11', 234) -grey15 = Colors.register(RGB(38, 38, 38), HSL(0, 0, 14), 'Grey15', 235) -grey19 = Colors.register(RGB(48, 48, 48), HSL(0, 0, 18), 'Grey19', 236) -grey23 = Colors.register(RGB(58, 58, 58), HSL(0, 0, 22), 'Grey23', 237) -grey27 = Colors.register(RGB(68, 68, 68), HSL(0, 0, 26), 'Grey27', 238) -grey30 = Colors.register(RGB(78, 78, 78), HSL(0, 0, 30), 'Grey30', 239) -grey35 = Colors.register(RGB(88, 88, 88), HSL(0, 0, 34), 'Grey35', 240) -grey39 = Colors.register(RGB(98, 98, 98), HSL(0, 0, 37), 'Grey39', 241) -grey42 = Colors.register(RGB(108, 108, 108), HSL(0, 0, 40), 'Grey42', 242) +grey11 = Colors.register(RGB(28, 28, 28), HSL(0, 0, 11), 'Grey11', 234) +grey15 = Colors.register(RGB(38, 38, 38), HSL(0, 0, 15), 'Grey15', 235) +grey19 = Colors.register(RGB(48, 48, 48), HSL(0, 0, 19), 'Grey19', 236) +grey23 = Colors.register(RGB(58, 58, 58), HSL(0, 0, 23), 'Grey23', 237) +grey27 = Colors.register(RGB(68, 68, 68), HSL(0, 0, 27), 'Grey27', 238) +grey30 = Colors.register(RGB(78, 78, 78), HSL(0, 0, 31), 'Grey30', 239) +grey35 = Colors.register(RGB(88, 88, 88), HSL(0, 0, 35), 'Grey35', 240) +grey39 = Colors.register(RGB(98, 98, 98), HSL(0, 0, 38), 'Grey39', 241) +grey42 = Colors.register(RGB(108, 108, 108), HSL(0, 0, 42), 'Grey42', 242) grey46 = Colors.register(RGB(118, 118, 118), HSL(0, 0, 46), 'Grey46', 243) grey50 = Colors.register(RGB(128, 128, 128), HSL(0, 0, 50), 'Grey50', 244) grey54 = Colors.register(RGB(138, 138, 138), HSL(0, 0, 54), 'Grey54', 245) grey58 = Colors.register(RGB(148, 148, 148), HSL(0, 0, 58), 'Grey58', 246) -grey62 = Colors.register(RGB(158, 158, 158), HSL(0, 0, 61), 'Grey62', 247) -grey66 = Colors.register(RGB(168, 168, 168), HSL(0, 0, 65), 'Grey66', 248) -grey70 = Colors.register(RGB(178, 178, 178), HSL(0, 0, 69), 'Grey70', 249) -grey74 = Colors.register(RGB(188, 188, 188), HSL(0, 0, 73), 'Grey74', 250) -grey78 = Colors.register(RGB(198, 198, 198), HSL(0, 0, 77), 'Grey78', 251) -grey82 = Colors.register(RGB(208, 208, 208), HSL(0, 0, 81), 'Grey82', 252) +grey62 = Colors.register(RGB(158, 158, 158), HSL(0, 0, 62), 'Grey62', 247) +grey66 = Colors.register(RGB(168, 168, 168), HSL(0, 0, 66), 'Grey66', 248) +grey70 = Colors.register(RGB(178, 178, 178), HSL(0, 0, 70), 'Grey70', 249) +grey74 = Colors.register(RGB(188, 188, 188), HSL(0, 0, 74), 'Grey74', 250) +grey78 = Colors.register(RGB(198, 198, 198), HSL(0, 0, 78), 'Grey78', 251) +grey82 = Colors.register(RGB(208, 208, 208), HSL(0, 0, 82), 'Grey82', 252) grey85 = Colors.register(RGB(218, 218, 218), HSL(0, 0, 85), 'Grey85', 253) grey89 = Colors.register(RGB(228, 228, 228), HSL(0, 0, 89), 'Grey89', 254) grey93 = Colors.register(RGB(238, 238, 238), HSL(0, 0, 93), 'Grey93', 255) diff --git a/progressbar/utils.py b/progressbar/utils.py index ae7e612c..cb98994d 100644 --- a/progressbar/utils.py +++ b/progressbar/utils.py @@ -29,6 +29,8 @@ StringT = typing.TypeVar('StringT', bound=types.StringTypes) T = typing.TypeVar('T') +logger: logging.Logger = logging.getLogger(__name__) + class _ProgressListener(typing.Protocol): """Structural type for the bars the stream wrapper notifies. @@ -499,6 +501,5 @@ def __delattr__(self, name: str) -> None: raise AttributeError(f'No such attribute: {name}') -logger: logging.Logger = logging.getLogger(__name__) streams = StreamWrapper() atexit.register(streams.flush) diff --git a/progressbar/widgets.py b/progressbar/widgets.py index a8fd7508..eae0b7b8 100644 --- a/progressbar/widgets.py +++ b/progressbar/widgets.py @@ -298,9 +298,6 @@ def __call__(self, progress: ProgressBarMixinBase, data: Data) -> str: fg=None, bg=None, ) - # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict() - # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor | None]] = ( - # dict()) _len: collections.abc.Callable[[str | bytes], int] = len @functools.cached_property @@ -439,6 +436,9 @@ class Timer(FormatLabel, TimeSensitiveWidgetBase): def __init__( self, format='Elapsed Time: %(elapsed)s', **kwargs: typing.Any ): + # Backwards compatibility: very old configs used a bare ``%s`` + # placeholder for the elapsed time. Silently rewrite it to the named + # ``%(elapsed)s`` form the widget actually formats with. if '%s' in format and '%(elapsed)s' not in format: format = format.replace('%s', '%(elapsed)s') @@ -552,6 +552,9 @@ def __init__( format_na='ETA: N/A', **kwargs, ): + # Backwards compatibility: rewrite a legacy bare ``%s`` placeholder to + # the named ``%(eta)s`` form (see ``Timer.__init__`` for the same + # elapsed-time shim). if '%s' in format and '%(eta)s' not in format: format = format.replace('%s', '%(eta)s') @@ -691,7 +694,9 @@ class AdaptiveETA(ETA, SamplesMixin): """WidgetBase which attempts to estimate the time of arrival. Uses a sampled average of the speed based on the 10 last updates. - Very convenient for resuming the progress halfway. + Very convenient for resuming the progress halfway. For an estimate based + on an exponential moving average (EMA) of the speed instead of a windowed + sample, use `SmoothingETA`. """ exponential_smoothing: bool @@ -955,7 +960,8 @@ def __call__(self, progress: ProgressBarMixinBase, data: Data, width=None): return fill + marker # type: ignore -# Alias for backwards compatibility +# Legacy alias for `AnimatedMarker`, kept for backwards compatibility. Kept as +# a plain alias (no DeprecationWarning) until the next major version. RotatingMarker = AnimatedMarker @@ -986,11 +992,6 @@ class ColoredMixin: fg=colors.gradient, bg=None, ) - # _fixed_colors: ClassVar[dict[str, terminal.Color | None]] = dict( - # fg_none=colors.yellow, bg_none=None) - # _gradient_colors: ClassVar[dict[str, terminal.OptionalColor | - # None]] = dict(fg=colors.gradient, - # bg=None) class Percentage(FormatWidgetMixin, ColoredMixin, WidgetBase): @@ -1581,7 +1582,11 @@ def __call__( class DynamicMessage(Variable): - """Kept for backwards compatibility, please use `Variable` instead.""" + """Legacy alias for `Variable`; prefer `Variable` in new code. + + Kept as a plain subclass (no DeprecationWarning) until the next major + version. + """ class CurrentTime(FormatWidgetMixin, TimeSensitiveWidgetBase): diff --git a/tests/test_color.py b/tests/test_color.py index b3b77421..16905534 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -273,6 +273,16 @@ def test_rgb_to_hls(rgb, hls) -> None: assert terminal.HSL.from_rgb(rgb) == hls +def test_registered_color_hls_matches_rgb() -> None: + # Regression: the hand-entered HSL column in colors.py had corrupted + # rows (e.g. DeepSkyBlue4 #005f87 stored hue 97 instead of 198), so + # gradients interpolated through the wrong hue. colors.py is now + # generated by tools/generate_colors.py, which derives every HSL from + # its RGB; this guards the two from ever drifting apart again. + for color in Colors.by_xterm.values(): + assert color.hls == terminal.HSL.from_rgb(color.rgb) + + @pytest.mark.parametrize( 'rgb, expected', [ diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..bf3d3f76 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,17 @@ +# Developer tools + +Maintenance scripts that are not shipped as part of the package. + +## `generate_colors.py` + +Regenerates the 256-colour table in `progressbar/terminal/colors.py`, +deriving every `HSL` value from its `RGB` via `HSL.from_rgb` (the RGB, xterm +index, colour name and Python binding name are authoritative and left +untouched). Run it in place: + +```console +python tools/generate_colors.py progressbar/terminal/colors.py +``` + +It is idempotent; `--check` verifies the committed file is up to date +without writing. diff --git a/tools/generate_colors.py b/tools/generate_colors.py new file mode 100644 index 00000000..454e2340 --- /dev/null +++ b/tools/generate_colors.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Regenerate ``progressbar/terminal/colors.py`` with canonical HSL values. + +The 256-colour table is *data*, not logic. For every entry the RGB triple, +the xterm index, the canonical colour name and the Python binding name are +authoritative and must never change: importers reference the binding names +directly, including the deliberate last-wins duplicates (``blue3``, +``deep_sky_blue4``, ...). Only the HSL triple is *derived*, and it is +derived here from the RGB via :meth:`progressbar.terminal.base.HSL.from_rgb` +so the stored value can never drift away from the RGB again. + +The hand-entered HSL column had corrupted rows -- for example +``DeepSkyBlue4`` (``#005f87``) stored hue ``97`` where the real hue is +``198`` -- which made every gradient that interpolated through those +colours blend through the wrong hue. Recomputing from RGB fixes the data at +its source. + +Usage (rewrites the file in place):: + + python tools/generate_colors.py progressbar/terminal/colors.py + +The generator parses the *current* file to recover the ordered +``(binding, RGB, name, xterm)`` tuples, so name/order fidelity is +guaranteed however the file happens to be reflowed. It is idempotent: +running it twice produces a byte-identical file, because the HSL column is +always recomputed from RGB and never read back. Pass ``--check`` to verify +that without writing (exit status 1 if the file is stale). +""" + +from __future__ import annotations + +import argparse +import ast +import pathlib +import sys +import typing + +# Import the real HSL/RGB so the generated values match runtime exactly. +_REPO_ROOT: pathlib.Path = pathlib.Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from progressbar.terminal.base import HSL, RGB # noqa: E402 + +#: Maximum line length (matches ``ruff.toml``); longer calls are wrapped one +#: argument per line, exactly as ``ruff format`` would. +LINE_LENGTH: int = 79 + + +class Entry(typing.NamedTuple): + """One authoritative colour-table row; HSL is derived, not stored here.""" + + binding: str + rgb: RGB + name: str + xterm: int + + +def _int_constant(node: ast.expr) -> int: + if not isinstance(node, ast.Constant) or not isinstance(node.value, int): + raise TypeError(f'expected an int literal, got {ast.dump(node)}') + return node.value + + +def _str_constant(node: ast.expr) -> str: + if not isinstance(node, ast.Constant) or not isinstance(node.value, str): + raise TypeError(f'expected a str literal, got {ast.dump(node)}') + return node.value + + +def _is_register_call(node: ast.stmt) -> bool: + return ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == 'register' + and isinstance(node.value.func.value, ast.Name) + and node.value.func.value.id == 'Colors' + ) + + +def _parse_rgb(node: ast.expr) -> RGB: + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == 'RGB' + and len(node.args) == 3 + ): + raise ValueError(f'expected an RGB(...) call, got {ast.dump(node)}') + return RGB(*(_int_constant(arg) for arg in node.args)) + + +def parse_entries(source: str) -> tuple[list[Entry], str, str]: + """Extract the ordered colour entries plus the file header and footer. + + The header is everything before the first ``Colors.register`` assignment + and the footer everything after the last one; both are copied verbatim so + the non-generated aliases and gradients survive untouched. + """ + tree = ast.parse(source) + register_nodes = [ + typing.cast('ast.Assign', node) + for node in tree.body + if _is_register_call(node) + ] + if not register_nodes: + raise ValueError('no Colors.register(...) assignments found') + + first, last = register_nodes[0], register_nodes[-1] + # The register block must be one contiguous run; a stray statement in the + # middle would be silently dropped by the header/footer split below. + for node in tree.body: + if ( + first.lineno <= node.lineno <= last.lineno + and not _is_register_call(node) + ): + raise ValueError( + f'unexpected non-register statement at line {node.lineno}', + ) + + entries: list[Entry] = [] + for node in register_nodes: + call = typing.cast('ast.Call', node.value) + if len(call.args) != 4 or call.keywords: + raise ValueError( + f'unexpected register signature at line {node.lineno}', + ) + target = typing.cast('ast.Name', node.targets[0]) + entries.append( + Entry( + binding=target.id, + rgb=_parse_rgb(call.args[0]), + name=_str_constant(call.args[2]), + xterm=_int_constant(call.args[3]), + ), + ) + + lines = source.splitlines(keepends=True) + header = ''.join(lines[: first.lineno - 1]) + footer = ''.join(lines[last.end_lineno :]) + return entries, header, footer + + +def render_entry(entry: Entry) -> str: + """Render one register call, wrapping only when it exceeds the limit.""" + hsl = HSL.from_rgb(entry.rgb) + rgb_src = f'RGB({entry.rgb.red}, {entry.rgb.green}, {entry.rgb.blue})' + # ``from_rgb`` rounds to ints; render them as ints (no trailing ``.0``). + hsl_src = ( + f'HSL({int(hsl.hue)}, {int(hsl.saturation)}, {int(hsl.lightness)})' + ) + args = f'{rgb_src}, {hsl_src}, {entry.name!r}, {entry.xterm}' + single = f'{entry.binding} = Colors.register({args})' + if len(single) <= LINE_LENGTH: + return single + '\n' + + # Black/ruff style: one argument per line with a magic trailing comma. + return ( + f'{entry.binding} = Colors.register(\n' + f' {rgb_src},\n' + f' {hsl_src},\n' + f' {entry.name!r},\n' + f' {entry.xterm},\n' + ')\n' + ) + + +def render(source: str) -> str: + entries, header, footer = parse_entries(source) + body = ''.join(render_entry(entry) for entry in entries) + return header + body + footer + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + 'path', + type=pathlib.Path, + help='colors.py to regenerate in place', + ) + parser.add_argument( + '--check', + action='store_true', + help='do not write; exit 1 if the file would change', + ) + args = parser.parse_args(argv) + + source = args.path.read_text() + generated = render(source) + + if args.check: + if generated != source: + sys.stderr.write( + f'{args.path} is out of date; run generate_colors.py\n', + ) + return 1 + return 0 + + if generated != source: + args.path.write_text(generated) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())