diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 5c4903f14aa86b..270788dde736bf 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -1,14 +1,12 @@ import os import sys -from collections.abc import Callable, Iterator, Mapping -from dataclasses import dataclass, field, Field - COLORIZE = True # types if False: + from collections.abc import Iterator from typing import IO, Literal, Self, ClassVar _theme: Theme @@ -125,27 +123,21 @@ class CursesColors: # Python 3.13 and older. Deleting the variables ensures they don't remain in your # interactive shell's global scope. -class ThemeSection(Mapping[str, str]): + +class ThemeSection: """A mixin/base class for theme sections. It enables dictionary access to a section, as well as implements convenience methods. """ - # The two types below are just that: types to inform the type checker that the + # The type below is just that: a type to inform the type checker that the # mixin will work in context of those fields existing - __dataclass_fields__: ClassVar[dict[str, Field[str]]] - _name_to_value: Callable[[str], str] - - def __post_init__(self) -> None: - name_to_value = {} - for color_name in self.__dataclass_fields__: - name_to_value[color_name] = getattr(self, color_name) - super().__setattr__('_name_to_value', name_to_value.__getitem__) + _fields: ClassVar[tuple[str, ...]] def copy_with(self, **kwargs: str) -> Self: color_state: dict[str, str] = {} - for color_name in self.__dataclass_fields__: + for color_name in self._fields: color_state[color_name] = getattr(self, color_name) color_state.update(kwargs) return type(self)(**color_state) @@ -153,114 +145,259 @@ def copy_with(self, **kwargs: str) -> Self: @classmethod def no_colors(cls) -> Self: color_state: dict[str, str] = {} - for color_name in cls.__dataclass_fields__: + for color_name in cls._fields: color_state[color_name] = "" return cls(**color_state) + # Mapping protocol implementation + def __getitem__(self, key: str) -> str: - return self._name_to_value(key) + if key in self._fields: + return getattr(self, key) # type: ignore[no-any-return] + raise KeyError(key) def __len__(self) -> int: - return len(self.__dataclass_fields__) + return len(self._fields) def __iter__(self) -> Iterator[str]: - return iter(self.__dataclass_fields__) + return iter(self._fields) + def __contains__(self, key: object) -> bool: + return key in self._fields -@dataclass(frozen=True, kw_only=True) -class Argparse(ThemeSection): - usage: str = ANSIColors.BOLD_BLUE - prog: str = ANSIColors.BOLD_MAGENTA - prog_extra: str = ANSIColors.MAGENTA - heading: str = ANSIColors.BOLD_BLUE - summary_long_option: str = ANSIColors.CYAN - summary_short_option: str = ANSIColors.GREEN - summary_label: str = ANSIColors.YELLOW - summary_action: str = ANSIColors.GREEN - long_option: str = ANSIColors.BOLD_CYAN - short_option: str = ANSIColors.BOLD_GREEN - label: str = ANSIColors.BOLD_YELLOW - action: str = ANSIColors.BOLD_GREEN - default: str = ANSIColors.GREY - interpolated_value: str = ANSIColors.YELLOW - reset: str = ANSIColors.RESET - error: str = ANSIColors.BOLD_MAGENTA - warning: str = ANSIColors.BOLD_YELLOW - message: str = ANSIColors.MAGENTA - - -@dataclass(frozen=True, kw_only=True) -class Difflib(ThemeSection): - """A 'git diff'-like theme for `difflib.unified_diff`.""" - added: str = ANSIColors.GREEN - context: str = ANSIColors.RESET # context lines - header: str = ANSIColors.BOLD # eg "---" and "+++" lines - hunk: str = ANSIColors.CYAN # the "@@" lines - removed: str = ANSIColors.RED - reset: str = ANSIColors.RESET + def keys(self) -> tuple[str, ...]: + return self._fields + def values(self) -> tuple[str, ...]: + return tuple(getattr(self, f) for f in self._fields) -@dataclass(frozen=True, kw_only=True) -class LiveProfiler(ThemeSection): - """Theme section for the live profiling TUI (Tachyon profiler). + def items(self) -> tuple[tuple[str, str], ...]: + return tuple((f, getattr(self, f)) for f in self._fields) - Colors use CursesColors constants (BLACK, RED, GREEN, YELLOW, - BLUE, MAGENTA, CYAN, WHITE, DEFAULT). - """ - # Header colors - title_fg: int = CursesColors.CYAN - title_bg: int = CursesColors.DEFAULT + def get(self, key: str, default: str | None = None) -> str | None: + if key in self._fields: + return getattr(self, key) # type: ignore[no-any-return] + return default - # Status display colors - pid_fg: int = CursesColors.CYAN - uptime_fg: int = CursesColors.GREEN - time_fg: int = CursesColors.YELLOW - interval_fg: int = CursesColors.MAGENTA + def __repr__(self) -> str: + fields = ", ".join(f"{f}={getattr(self, f)!r}" for f in self._fields) + return f"{type(self).__name__}({fields})" - # Thread view colors - thread_all_fg: int = CursesColors.GREEN - thread_single_fg: int = CursesColors.MAGENTA + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return NotImplemented + return all(getattr(self, f) == getattr(other, f) for f in self._fields) - # Progress bar colors - bar_good_fg: int = CursesColors.GREEN - bar_bad_fg: int = CursesColors.RED + def __hash__(self) -> int: + return hash(tuple(getattr(self, f) for f in self._fields)) - # Stats colors - on_gil_fg: int = CursesColors.GREEN - off_gil_fg: int = CursesColors.RED - waiting_gil_fg: int = CursesColors.YELLOW - gc_fg: int = CursesColors.MAGENTA - # Function display colors - func_total_fg: int = CursesColors.CYAN - func_exec_fg: int = CursesColors.GREEN - func_stack_fg: int = CursesColors.YELLOW - func_shown_fg: int = CursesColors.MAGENTA +class Argparse(ThemeSection): + _fields = ( + "usage", + "prog", + "prog_extra", + "heading", + "summary_long_option", + "summary_short_option", + "summary_label", + "summary_action", + "long_option", + "short_option", + "label", + "action", + "default", + "interpolated_value", + "reset", + "error", + "warning", + "message", + ) + + def __init__( + self, + *, + usage: str = ANSIColors.BOLD_BLUE, + prog: str = ANSIColors.BOLD_MAGENTA, + prog_extra: str = ANSIColors.MAGENTA, + heading: str = ANSIColors.BOLD_BLUE, + summary_long_option: str = ANSIColors.CYAN, + summary_short_option: str = ANSIColors.GREEN, + summary_label: str = ANSIColors.YELLOW, + summary_action: str = ANSIColors.GREEN, + long_option: str = ANSIColors.BOLD_CYAN, + short_option: str = ANSIColors.BOLD_GREEN, + label: str = ANSIColors.BOLD_YELLOW, + action: str = ANSIColors.BOLD_GREEN, + default: str = ANSIColors.GREY, + interpolated_value: str = ANSIColors.YELLOW, + reset: str = ANSIColors.RESET, + error: str = ANSIColors.BOLD_MAGENTA, + warning: str = ANSIColors.BOLD_YELLOW, + message: str = ANSIColors.MAGENTA, + ): + self.usage = usage + self.prog = prog + self.prog_extra = prog_extra + self.heading = heading + self.summary_long_option = summary_long_option + self.summary_short_option = summary_short_option + self.summary_label = summary_label + self.summary_action = summary_action + self.long_option = long_option + self.short_option = short_option + self.label = label + self.action = action + self.default = default + self.interpolated_value = interpolated_value + self.reset = reset + self.error = error + self.warning = warning + self.message = message - # Table header colors (for sorted column highlight) - sorted_header_fg: int = CursesColors.BLACK - sorted_header_bg: int = CursesColors.CYAN - # Normal header colors (non-sorted columns) - use reverse video style - normal_header_fg: int = CursesColors.BLACK - normal_header_bg: int = CursesColors.WHITE +class Difflib(ThemeSection): + """A 'git diff'-like theme for `difflib.unified_diff`.""" - # Data row colors - samples_fg: int = CursesColors.CYAN - file_fg: int = CursesColors.GREEN - func_fg: int = CursesColors.YELLOW + _fields = ("added", "context", "header", "hunk", "removed", "reset") - # Trend indicator colors - trend_up_fg: int = CursesColors.GREEN - trend_down_fg: int = CursesColors.RED + def __init__( + self, + *, + added: str = ANSIColors.GREEN, + context: str = ANSIColors.RESET, # context lines + header: str = ANSIColors.BOLD, # eg "---" and "+++" lines + hunk: str = ANSIColors.CYAN, # the "@@" lines + removed: str = ANSIColors.RED, + reset: str = ANSIColors.RESET, + ): + self.added = added + self.context = context + self.header = header + self.hunk = hunk + self.removed = removed + self.reset = reset - # Medal colors for top functions - medal_gold_fg: int = CursesColors.RED - medal_silver_fg: int = CursesColors.YELLOW - medal_bronze_fg: int = CursesColors.GREEN - # Background style: 'dark' or 'light' - background_style: Literal["dark", "light"] = "dark" +class LiveProfiler(ThemeSection): + """Theme section for the live profiling TUI (Tachyon profiler). + + Colors use CursesColors constants (BLACK, RED, GREEN, YELLOW, + BLUE, MAGENTA, CYAN, WHITE, DEFAULT). + """ + + _fields = ( + "title_fg", + "title_bg", + "pid_fg", + "uptime_fg", + "time_fg", + "interval_fg", + "thread_all_fg", + "thread_single_fg", + "bar_good_fg", + "bar_bad_fg", + "on_gil_fg", + "off_gil_fg", + "waiting_gil_fg", + "gc_fg", + "func_total_fg", + "func_exec_fg", + "func_stack_fg", + "func_shown_fg", + "sorted_header_fg", + "sorted_header_bg", + "normal_header_fg", + "normal_header_bg", + "samples_fg", + "file_fg", + "func_fg", + "trend_up_fg", + "trend_down_fg", + "medal_gold_fg", + "medal_silver_fg", + "medal_bronze_fg", + "background_style", + ) + + def __init__( + self, + *, + # Header colors + title_fg: int = CursesColors.CYAN, + title_bg: int = CursesColors.DEFAULT, + # Status display colors + pid_fg: int = CursesColors.CYAN, + uptime_fg: int = CursesColors.GREEN, + time_fg: int = CursesColors.YELLOW, + interval_fg: int = CursesColors.MAGENTA, + # Thread view colors + thread_all_fg: int = CursesColors.GREEN, + thread_single_fg: int = CursesColors.MAGENTA, + # Progress bar colors + bar_good_fg: int = CursesColors.GREEN, + bar_bad_fg: int = CursesColors.RED, + # Stats colors + on_gil_fg: int = CursesColors.GREEN, + off_gil_fg: int = CursesColors.RED, + waiting_gil_fg: int = CursesColors.YELLOW, + gc_fg: int = CursesColors.MAGENTA, + # Function display colors + func_total_fg: int = CursesColors.CYAN, + func_exec_fg: int = CursesColors.GREEN, + func_stack_fg: int = CursesColors.YELLOW, + func_shown_fg: int = CursesColors.MAGENTA, + # Table header colors (for sorted column highlight) + sorted_header_fg: int = CursesColors.BLACK, + sorted_header_bg: int = CursesColors.CYAN, + # Normal header colors (non-sorted columns) - use reverse video style + normal_header_fg: int = CursesColors.BLACK, + normal_header_bg: int = CursesColors.WHITE, + # Data row colors + samples_fg: int = CursesColors.CYAN, + file_fg: int = CursesColors.GREEN, + func_fg: int = CursesColors.YELLOW, + # Trend indicator colors + trend_up_fg: int = CursesColors.GREEN, + trend_down_fg: int = CursesColors.RED, + # Medal colors for top functions + medal_gold_fg: int = CursesColors.RED, + medal_silver_fg: int = CursesColors.YELLOW, + medal_bronze_fg: int = CursesColors.GREEN, + # Background style: 'dark' or 'light' + background_style: Literal["dark", "light"] = "dark", + ): + self.title_fg = title_fg + self.title_bg = title_bg + self.pid_fg = pid_fg + self.uptime_fg = uptime_fg + self.time_fg = time_fg + self.interval_fg = interval_fg + self.thread_all_fg = thread_all_fg + self.thread_single_fg = thread_single_fg + self.bar_good_fg = bar_good_fg + self.bar_bad_fg = bar_bad_fg + self.on_gil_fg = on_gil_fg + self.off_gil_fg = off_gil_fg + self.waiting_gil_fg = waiting_gil_fg + self.gc_fg = gc_fg + self.func_total_fg = func_total_fg + self.func_exec_fg = func_exec_fg + self.func_stack_fg = func_stack_fg + self.func_shown_fg = func_shown_fg + self.sorted_header_fg = sorted_header_fg + self.sorted_header_bg = sorted_header_bg + self.normal_header_fg = normal_header_fg + self.normal_header_bg = normal_header_bg + self.samples_fg = samples_fg + self.file_fg = file_fg + self.func_fg = func_fg + self.trend_up_fg = trend_up_fg + self.trend_down_fg = trend_down_fg + self.medal_gold_fg = medal_gold_fg + self.medal_silver_fg = medal_silver_fg + self.medal_bronze_fg = medal_bronze_fg + self.background_style = background_style LiveProfilerLight = LiveProfiler( @@ -308,55 +445,136 @@ class LiveProfiler(ThemeSection): ) -@dataclass(frozen=True, kw_only=True) class Syntax(ThemeSection): - prompt: str = ANSIColors.BOLD_MAGENTA - keyword: str = ANSIColors.BOLD_BLUE - keyword_constant: str = ANSIColors.BOLD_BLUE - builtin: str = ANSIColors.CYAN - comment: str = ANSIColors.RED - string: str = ANSIColors.GREEN - number: str = ANSIColors.YELLOW - op: str = ANSIColors.RESET - definition: str = ANSIColors.BOLD - soft_keyword: str = ANSIColors.BOLD_BLUE - reset: str = ANSIColors.RESET - - -@dataclass(frozen=True, kw_only=True) + _fields = ( + "prompt", + "keyword", + "keyword_constant", + "builtin", + "comment", + "string", + "number", + "op", + "definition", + "soft_keyword", + "reset", + ) + + def __init__( + self, + *, + prompt: str = ANSIColors.BOLD_MAGENTA, + keyword: str = ANSIColors.BOLD_BLUE, + keyword_constant: str = ANSIColors.BOLD_BLUE, + builtin: str = ANSIColors.CYAN, + comment: str = ANSIColors.RED, + string: str = ANSIColors.GREEN, + number: str = ANSIColors.YELLOW, + op: str = ANSIColors.RESET, + definition: str = ANSIColors.BOLD, + soft_keyword: str = ANSIColors.BOLD_BLUE, + reset: str = ANSIColors.RESET, + ): + self.prompt = prompt + self.keyword = keyword + self.keyword_constant = keyword_constant + self.builtin = builtin + self.comment = comment + self.string = string + self.number = number + self.op = op + self.definition = definition + self.soft_keyword = soft_keyword + self.reset = reset + + class Traceback(ThemeSection): - type: str = ANSIColors.BOLD_MAGENTA - message: str = ANSIColors.MAGENTA - filename: str = ANSIColors.MAGENTA - line_no: str = ANSIColors.MAGENTA - frame: str = ANSIColors.MAGENTA - error_highlight: str = ANSIColors.BOLD_RED - error_range: str = ANSIColors.RED - reset: str = ANSIColors.RESET + _fields = ( + "type", + "message", + "filename", + "line_no", + "frame", + "error_highlight", + "error_range", + "reset", + ) + + def __init__( + self, + *, + type: str = ANSIColors.BOLD_MAGENTA, + message: str = ANSIColors.MAGENTA, + filename: str = ANSIColors.MAGENTA, + line_no: str = ANSIColors.MAGENTA, + frame: str = ANSIColors.MAGENTA, + error_highlight: str = ANSIColors.BOLD_RED, + error_range: str = ANSIColors.RED, + reset: str = ANSIColors.RESET, + ): + self.type = type + self.message = message + self.filename = filename + self.line_no = line_no + self.frame = frame + self.error_highlight = error_highlight + self.error_range = error_range + self.reset = reset -@dataclass(frozen=True, kw_only=True) class Unittest(ThemeSection): - passed: str = ANSIColors.GREEN - warn: str = ANSIColors.YELLOW - fail: str = ANSIColors.RED - fail_info: str = ANSIColors.BOLD_RED - reset: str = ANSIColors.RESET + _fields = ("passed", "warn", "fail", "fail_info", "reset") + + def __init__( + self, + *, + passed: str = ANSIColors.GREEN, + warn: str = ANSIColors.YELLOW, + fail: str = ANSIColors.RED, + fail_info: str = ANSIColors.BOLD_RED, + reset: str = ANSIColors.RESET, + ): + self.passed = passed + self.warn = warn + self.fail = fail + self.fail_info = fail_info + self.reset = reset -@dataclass(frozen=True, kw_only=True) class Theme: """A suite of themes for all sections of Python. When adding a new one, remember to also modify `copy_with` and `no_colors` below. """ - argparse: Argparse = field(default_factory=Argparse) - difflib: Difflib = field(default_factory=Difflib) - live_profiler: LiveProfiler = field(default_factory=LiveProfiler) - syntax: Syntax = field(default_factory=Syntax) - traceback: Traceback = field(default_factory=Traceback) - unittest: Unittest = field(default_factory=Unittest) + + _fields = ( + "argparse", + "difflib", + "live_profiler", + "syntax", + "traceback", + "unittest", + ) + + def __init__( + self, + *, + argparse: Argparse | None = None, + difflib: Difflib | None = None, + live_profiler: LiveProfiler | None = None, + syntax: Syntax | None = None, + traceback: Traceback | None = None, + unittest: Unittest | None = None, + ): + self.argparse = argparse if argparse is not None else Argparse() + self.difflib = difflib if difflib is not None else Difflib() + self.live_profiler = ( + live_profiler if live_profiler is not None else LiveProfiler() + ) + self.syntax = syntax if syntax is not None else Syntax() + self.traceback = traceback if traceback is not None else Traceback() + self.unittest = unittest if unittest is not None else Unittest() def copy_with( self, @@ -399,6 +617,18 @@ def no_colors(cls) -> Self: unittest=Unittest.no_colors(), ) + def __repr__(self) -> str: + fields = ", ".join(f"{f}={getattr(self, f)!r}" for f in self._fields) + return f"{type(self).__name__}({fields})" + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return NotImplemented + return all(getattr(self, f) == getattr(other, f) for f in self._fields) + + def __hash__(self) -> int: + return hash(tuple(getattr(self, f) for f in self._fields)) + def get_colors( colorize: bool = False, *, file: IO[str] | IO[bytes] | None = None diff --git a/Lib/test/test__colorize.py b/Lib/test/test__colorize.py index 67e0595943d356..a51c5b079312b8 100644 --- a/Lib/test/test__colorize.py +++ b/Lib/test/test__colorize.py @@ -1,5 +1,4 @@ import contextlib -import dataclasses import io import sys import unittest @@ -26,10 +25,26 @@ class TestTheme(unittest.TestCase): def test_attributes(self): # only theme configurations attributes by default - for field in dataclasses.fields(_colorize.Theme): - with self.subTest(field.name): - self.assertIsSubclass(field.type, _colorize.ThemeSection) - self.assertIsNotNone(field.default_factory) + for field in _colorize.Theme._fields: + with self.subTest(field): + section = getattr(_colorize.default_theme, field) + self.assertIsInstance(section, _colorize.ThemeSection) + + def test_fields_match_init_parameters(self): + classes = [ + _colorize.Argparse, + _colorize.Difflib, + _colorize.LiveProfiler, + _colorize.Syntax, + _colorize.Traceback, + _colorize.Unittest, + ] + for cls in classes: + with self.subTest(cls=cls.__name__): + code = cls.__init__.__code__ + # All __init__ params are keyword-only (after self) + params = set(code.co_varnames[1 : 1 + code.co_kwonlyargcount]) + self.assertEqual(params, set(cls._fields)) def test_copy_with(self): theme = _colorize.Theme() @@ -52,10 +67,11 @@ def test_no_colors(self): self.assertEqual(theme_no_colors, theme_no_colors_no_colors) # attributes check - for section in dataclasses.fields(_colorize.Theme): - with self.subTest(section.name): - section_theme = getattr(theme_no_colors, section.name) - self.assertEqual(section_theme, section.type.no_colors()) + for section in _colorize.Theme._fields: + with self.subTest(section): + section_theme = getattr(theme_no_colors, section) + section_cls = type(section_theme) + self.assertEqual(section_theme, section_cls.no_colors()) class TestColorizeFunction(unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2026-02-16-15-32-57.gh-issue-144384.KNXAp7.rst b/Misc/NEWS.d/next/Library/2026-02-16-15-32-57.gh-issue-144384.KNXAp7.rst new file mode 100644 index 00000000000000..1ceed97fe37811 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-16-15-32-57.gh-issue-144384.KNXAp7.rst @@ -0,0 +1,2 @@ +Improve ``_colorize`` performance by replacing ``dataclass`` with regular class. +Patch by Hugo van Kemenade.