From 33440084bf7b799aa017a74906e27b5e7038e15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 3 May 2025 17:14:13 +0200 Subject: [PATCH 01/19] gh-133346: Make theming support in _colorize extensible --- Lib/_colorize.py | 118 +++++++++++++++++++--------- Lib/_pyrepl/reader.py | 7 +- Lib/_pyrepl/utils.py | 34 ++++---- Lib/test/test_pyrepl/test_reader.py | 6 +- 4 files changed, 109 insertions(+), 56 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 54895488e740d0..e32f34c996952b 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -1,28 +1,17 @@ -from __future__ import annotations import io import os import sys +from collections.abc import Callable, Iterator, Mapping +from dataclasses import dataclass, field, Field + COLORIZE = True + # types if False: - from typing import IO, Literal - - type ColorTag = Literal[ - "PROMPT", - "KEYWORD", - "BUILTIN", - "COMMENT", - "STRING", - "NUMBER", - "OP", - "DEFINITION", - "SOFT_KEYWORD", - "RESET", - ] - - theme: dict[ColorTag, str] + from typing import IO, Self, ClassVar + _theme: Theme class ANSIColors: @@ -86,6 +75,68 @@ class ANSIColors: setattr(NoColors, attr, "") +class ThemeSection(Mapping[str, str]): + __dataclass_fields__: ClassVar[dict[str, Field[str]]] + _name_to_value: Callable[[str], str] + + def __post_init__(self) -> None: + name_to_value = {} + for color_name, color_field in self.__dataclass_fields__.items(): + name_to_value[color_name] = getattr(self, color_name) + super().__setattr__('_name_to_value', name_to_value.__getitem__) + + def copy_with(self, **kwargs: str) -> Self: + color_state: dict[str, str] = {} + for color_name, color_field in self.__dataclass_fields__.items(): + color_state[color_name] = getattr(self, color_name) + color_state.update(kwargs) + return type(self)(**color_state) + + def no_colors(self) -> Self: + color_state: dict[str, str] = {} + for color_name, color_field in self.__dataclass_fields__.items(): + color_state[color_name] = "" + return type(self)(**color_state) + + def __getitem__(self, key: str) -> str: + return self._name_to_value(key) + + def __len__(self) -> int: + return len(self.__dataclass_fields__) + + def __iter__(self) -> Iterator[str]: + return iter(self.__dataclass_fields__) + + +@dataclass(frozen=True) +class REPL(ThemeSection): + prompt: str = ANSIColors.BOLD_MAGENTA + keyword: 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) +class Theme: + repl: REPL = field(default_factory=REPL) + + def copy_with(self, *, repl: REPL | None) -> Self: + return type(self)( + repl=repl or self.repl, + ) + + def no_colors(self) -> Self: + return type(self)( + repl=self.repl.no_colors(), + ) + + def get_colors( colorize: bool = False, *, file: IO[str] | IO[bytes] | None = None ) -> ANSIColors: @@ -138,26 +189,21 @@ def can_colorize(*, file: IO[str] | IO[bytes] | None = None) -> bool: return hasattr(file, "isatty") and file.isatty() -def set_theme(t: dict[ColorTag, str] | None = None) -> None: - global theme +default_theme = Theme() +theme_no_color = default_theme.no_colors() + + +def get_theme(*, tty_file: IO[str] | IO[bytes] | None = None) -> Theme: + if can_colorize(file=tty_file): + return _theme + return theme_no_color + - if t: - theme = t - return +def set_theme(t: Theme) -> None: + global _theme - colors = get_colors() - theme = { - "PROMPT": colors.BOLD_MAGENTA, - "KEYWORD": colors.BOLD_BLUE, - "BUILTIN": colors.CYAN, - "COMMENT": colors.RED, - "STRING": colors.GREEN, - "NUMBER": colors.YELLOW, - "OP": colors.RESET, - "DEFINITION": colors.BOLD, - "SOFT_KEYWORD": colors.BOLD_BLUE, - "RESET": colors.RESET, - } + _theme = t + return -set_theme() +set_theme(default_theme) \ No newline at end of file diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 65c2230dfd65f7..4686eeb5026acd 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -28,7 +28,7 @@ from dataclasses import dataclass, field, fields from . import commands, console, input -from .utils import wlen, unbracket, disp_str, gen_colors +from .utils import wlen, unbracket, disp_str, gen_colors, THEME from .trace import trace @@ -491,10 +491,11 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: prompt = self.ps1 if self.can_colorize: + t = THEME() prompt = ( - f"{_colorize.theme["PROMPT"]}" + f"{t.prompt}" f"{prompt}" - f"{_colorize.theme["RESET"]}" + f"{t.reset}" ) return prompt diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index fe154aa59a00fe..8d80ab93b28bcd 100644 --- a/Lib/_pyrepl/utils.py +++ b/Lib/_pyrepl/utils.py @@ -23,6 +23,11 @@ BUILTINS = {str(name) for name in dir(builtins) if not name.startswith('_')} +def THEME(): + # Not cached: the user can modify the theme inside the interactive session. + return _colorize.get_theme().repl + + class Span(NamedTuple): """Span indexing that's inclusive on both ends.""" @@ -44,7 +49,7 @@ def from_token(cls, token: TI, line_len: list[int]) -> Self: class ColorSpan(NamedTuple): span: Span - tag: _colorize.ColorTag + tag: str @functools.cache @@ -135,7 +140,7 @@ def recover_unterminated_string( span = Span(start, end) trace("yielding span {a} -> {b}", a=span.start, b=span.end) - yield ColorSpan(span, "STRING") + yield ColorSpan(span, "string") else: trace( "unhandled token error({buffer}) = {te}", @@ -164,28 +169,28 @@ def gen_colors_from_token_stream( | T.TSTRING_START | T.TSTRING_MIDDLE | T.TSTRING_END ): span = Span.from_token(token, line_lengths) - yield ColorSpan(span, "STRING") + yield ColorSpan(span, "string") case T.COMMENT: span = Span.from_token(token, line_lengths) - yield ColorSpan(span, "COMMENT") + yield ColorSpan(span, "comment") case T.NUMBER: span = Span.from_token(token, line_lengths) - yield ColorSpan(span, "NUMBER") + yield ColorSpan(span, "number") case T.OP: if token.string in "([{": bracket_level += 1 elif token.string in ")]}": bracket_level -= 1 span = Span.from_token(token, line_lengths) - yield ColorSpan(span, "OP") + yield ColorSpan(span, "op") case T.NAME: if is_def_name: is_def_name = False span = Span.from_token(token, line_lengths) - yield ColorSpan(span, "DEFINITION") + yield ColorSpan(span, "definition") elif keyword.iskeyword(token.string): span = Span.from_token(token, line_lengths) - yield ColorSpan(span, "KEYWORD") + yield ColorSpan(span, "keyword") if token.string in IDENTIFIERS_AFTER: is_def_name = True elif ( @@ -194,10 +199,10 @@ def gen_colors_from_token_stream( and is_soft_keyword_used(prev_token, token, next_token) ): span = Span.from_token(token, line_lengths) - yield ColorSpan(span, "SOFT_KEYWORD") + yield ColorSpan(span, "soft_keyword") elif token.string in BUILTINS: span = Span.from_token(token, line_lengths) - yield ColorSpan(span, "BUILTIN") + yield ColorSpan(span, "builtin") keyword_first_sets_match = {"False", "None", "True", "await", "lambda", "not"} @@ -290,15 +295,16 @@ def disp_str( # move past irrelevant spans colors.pop(0) + theme = THEME() pre_color = "" post_color = "" if colors and colors[0].span.start < start_index: # looks like we're continuing a previous color (e.g. a multiline str) - pre_color = _colorize.theme[colors[0].tag] + pre_color = theme[colors[0].tag] for i, c in enumerate(buffer, start_index): if colors and colors[0].span.start == i: # new color starts now - pre_color = _colorize.theme[colors[0].tag] + pre_color = theme[colors[0].tag] if c == "\x1a": # CTRL-Z on Windows chars.append(c) @@ -315,7 +321,7 @@ def disp_str( char_widths.append(str_width(c)) if colors and colors[0].span.end == i: # current color ends now - post_color = _colorize.theme["RESET"] + post_color = theme.reset colors.pop(0) chars[-1] = pre_color + chars[-1] + post_color @@ -325,7 +331,7 @@ def disp_str( if colors and colors[0].span.start < i and colors[0].span.end > i: # even though the current color should be continued, reset it for now. # the next call to `disp_str()` will revive it. - chars[-1] += _colorize.theme["RESET"] + chars[-1] += theme.reset return chars, char_widths diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 8d7fcf538d2064..d8c3630cac0bbe 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -11,11 +11,11 @@ from .support import reader_no_colors as prepare_reader from _pyrepl.console import Event from _pyrepl.reader import Reader -from _colorize import theme +from _colorize import get_theme -overrides = {"RESET": "z", "SOFT_KEYWORD": "K"} -colors = {overrides.get(k, k[0].lower()): v for k, v in theme.items()} +overrides = {"reset": "z", "soft_keyword": "K"} +colors = {overrides.get(k, k[0].lower()): v for k, v in get_theme().repl.items()} class TestReader(ScreenEqualMixin, TestCase): From 68e23853da54315b6d9afdfa5c1e3dffdcd45513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 3 May 2025 17:17:25 +0200 Subject: [PATCH 02/19] Fix lint --- Lib/_colorize.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index e32f34c996952b..6024c56976a559 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -97,13 +97,13 @@ def no_colors(self) -> Self: for color_name, color_field in self.__dataclass_fields__.items(): color_state[color_name] = "" return type(self)(**color_state) - + def __getitem__(self, key: str) -> str: return self._name_to_value(key) - + def __len__(self) -> int: return len(self.__dataclass_fields__) - + def __iter__(self) -> Iterator[str]: return iter(self.__dataclass_fields__) @@ -206,4 +206,4 @@ def set_theme(t: Theme) -> None: return -set_theme(default_theme) \ No newline at end of file +set_theme(default_theme) From 252dfa059f2a8784b4a802198ae8a4d83bf5b31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 3 May 2025 18:42:20 +0200 Subject: [PATCH 03/19] Add theming to traceback.py --- Lib/_colorize.py | 24 +++++- Lib/asyncio/__main__.py | 7 +- Lib/test/test_pyrepl/test_reader.py | 4 +- Lib/test/test_traceback.py | 114 ++++++++++++++-------------- Lib/traceback.py | 102 +++++++++++-------------- 5 files changed, 132 insertions(+), 119 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 6024c56976a559..5942fc6bdc34e2 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -122,18 +122,38 @@ class REPL(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=True) +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 + + @dataclass(frozen=True) class Theme: repl: REPL = field(default_factory=REPL) - - def copy_with(self, *, repl: REPL | None) -> Self: + traceback: Traceback = field(default_factory=Traceback) + + def copy_with( + self, + *, + repl: REPL | None = None, + traceback: Traceback | None = None, + ) -> Self: return type(self)( repl=repl or self.repl, + traceback=traceback or self.traceback, ) def no_colors(self) -> Self: return type(self)( repl=self.repl.no_colors(), + traceback=self.traceback.no_colors(), ) diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index 69f5a30cfe5095..7bb0fe3b078abd 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -10,7 +10,7 @@ import types import warnings -from _colorize import can_colorize, ANSIColors # type: ignore[import-not-found] +from _colorize import get_theme from _pyrepl.console import InteractiveColoredConsole from . import futures @@ -101,8 +101,9 @@ def run(self): exec(startup_code, console.locals) ps1 = getattr(sys, "ps1", ">>> ") - if can_colorize() and CAN_USE_PYREPL: - ps1 = f"{ANSIColors.BOLD_MAGENTA}{ps1}{ANSIColors.RESET}" + if CAN_USE_PYREPL: + theme = get_theme().repl + ps1 = f"{theme.prompt}{ps1}{theme.reset}" console.write(f"{ps1}import asyncio\n") if CAN_USE_PYREPL: diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index d8c3630cac0bbe..2de651574189e6 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -11,11 +11,11 @@ from .support import reader_no_colors as prepare_reader from _pyrepl.console import Event from _pyrepl.reader import Reader -from _colorize import get_theme +from _colorize import default_theme overrides = {"reset": "z", "soft_keyword": "K"} -colors = {overrides.get(k, k[0].lower()): v for k, v in get_theme().repl.items()} +colors = {overrides.get(k, k[0].lower()): v for k, v in default_theme.repl.items()} class TestReader(ScreenEqualMixin, TestCase): diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 683486e9aca7b2..b9be87f357ffdd 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -37,6 +37,12 @@ test_frame = namedtuple('frame', ['f_code', 'f_globals', 'f_locals']) test_tb = namedtuple('tb', ['tb_frame', 'tb_lineno', 'tb_next', 'tb_lasti']) +color_overrides = {"reset": "z", "filename": "fn", "error_highlight": "E"} +colors = { + color_overrides.get(k, k[0].lower()): v + for k, v in _colorize.default_theme.traceback.items() +} + LEVENSHTEIN_DATA_FILE = Path(__file__).parent / 'levenshtein_examples.json' @@ -4721,6 +4727,8 @@ class MyList(list): class TestColorizedTraceback(unittest.TestCase): + maxDiff = None + def test_colorized_traceback(self): def foo(*args): x = {'a':{'b': None}} @@ -4743,9 +4751,9 @@ def bar(): e, capture_locals=True ) lines = "".join(exc.format(colorize=True)) - red = _colorize.ANSIColors.RED - boldr = _colorize.ANSIColors.BOLD_RED - reset = _colorize.ANSIColors.RESET + red = colors["e"] + boldr = colors["E"] + reset = colors["z"] self.assertIn("y = " + red + "x['a']['b']" + reset + boldr + "['c']" + reset, lines) self.assertIn("return " + red + "(lambda *args: foo(*args))" + reset + boldr + "(1,2,3,4)" + reset, lines) self.assertIn("return (lambda *args: " + red + "foo" + reset + boldr + "(*args)" + reset + ")(1,2,3,4)", lines) @@ -4761,18 +4769,16 @@ def test_colorized_syntax_error(self): e, capture_locals=True ) actual = "".join(exc.format(colorize=True)) - red = _colorize.ANSIColors.RED - magenta = _colorize.ANSIColors.MAGENTA - boldm = _colorize.ANSIColors.BOLD_MAGENTA - boldr = _colorize.ANSIColors.BOLD_RED - reset = _colorize.ANSIColors.RESET - expected = "".join([ - f' File {magenta}""{reset}, line {magenta}1{reset}\n', - f' a {boldr}${reset} b\n', - f' {boldr}^{reset}\n', - f'{boldm}SyntaxError{reset}: {magenta}invalid syntax{reset}\n'] - ) - self.assertIn(expected, actual) + def expected(t, m, fn, l, f, E, e, z): + return "".join( + [ + f' File {fn}""{z}, line {l}1{z}\n', + f' a {E}${z} b\n', + f' {E}^{z}\n', + f'{t}SyntaxError{z}: {m}invalid syntax{z}\n' + ] + ) + self.assertIn(expected(**colors), actual) def test_colorized_traceback_is_the_default(self): def foo(): @@ -4788,23 +4794,21 @@ def foo(): exception_print(e) actual = tbstderr.getvalue().splitlines() - red = _colorize.ANSIColors.RED - boldr = _colorize.ANSIColors.BOLD_RED - magenta = _colorize.ANSIColors.MAGENTA - boldm = _colorize.ANSIColors.BOLD_MAGENTA - reset = _colorize.ANSIColors.RESET lno_foo = foo.__code__.co_firstlineno - expected = ['Traceback (most recent call last):', - f' File {magenta}"{__file__}"{reset}, ' - f'line {magenta}{lno_foo+5}{reset}, in {magenta}test_colorized_traceback_is_the_default{reset}', - f' {red}foo{reset+boldr}(){reset}', - f' {red}~~~{reset+boldr}^^{reset}', - f' File {magenta}"{__file__}"{reset}, ' - f'line {magenta}{lno_foo+1}{reset}, in {magenta}foo{reset}', - f' {red}1{reset+boldr}/{reset+red}0{reset}', - f' {red}~{reset+boldr}^{reset+red}~{reset}', - f'{boldm}ZeroDivisionError{reset}: {magenta}division by zero{reset}'] - self.assertEqual(actual, expected) + def expected(t, m, fn, l, f, E, e, z): + return [ + 'Traceback (most recent call last):', + f' File {fn}"{__file__}"{z}, ' + f'line {l}{lno_foo+5}{z}, in {f}test_colorized_traceback_is_the_default{z}', + f' {e}foo{z}{E}(){z}', + f' {e}~~~{z}{E}^^{z}', + f' File {fn}"{__file__}"{z}, ' + f'line {l}{lno_foo+1}{z}, in {f}foo{z}', + f' {e}1{z}{E}/{z}{e}0{z}', + f' {e}~{z}{E}^{z}{e}~{z}', + f'{t}ZeroDivisionError{z}: {m}division by zero{z}', + ] + self.assertEqual(actual, expected(**colors)) def test_colorized_traceback_from_exception_group(self): def foo(): @@ -4822,33 +4826,31 @@ def foo(): e, capture_locals=True ) - red = _colorize.ANSIColors.RED - boldr = _colorize.ANSIColors.BOLD_RED - magenta = _colorize.ANSIColors.MAGENTA - boldm = _colorize.ANSIColors.BOLD_MAGENTA - reset = _colorize.ANSIColors.RESET lno_foo = foo.__code__.co_firstlineno actual = "".join(exc.format(colorize=True)).splitlines() - expected = [f" + Exception Group Traceback (most recent call last):", - f' | File {magenta}"{__file__}"{reset}, line {magenta}{lno_foo+9}{reset}, in {magenta}test_colorized_traceback_from_exception_group{reset}', - f' | {red}foo{reset}{boldr}(){reset}', - f' | {red}~~~{reset}{boldr}^^{reset}', - f" | e = ExceptionGroup('test', [ZeroDivisionError('division by zero')])", - f" | foo = {foo}", - f' | self = <{__name__}.TestColorizedTraceback testMethod=test_colorized_traceback_from_exception_group>', - f' | File {magenta}"{__file__}"{reset}, line {magenta}{lno_foo+6}{reset}, in {magenta}foo{reset}', - f' | raise ExceptionGroup("test", exceptions)', - f" | exceptions = [ZeroDivisionError('division by zero')]", - f' | {boldm}ExceptionGroup{reset}: {magenta}test (1 sub-exception){reset}', - f' +-+---------------- 1 ----------------', - f' | Traceback (most recent call last):', - f' | File {magenta}"{__file__}"{reset}, line {magenta}{lno_foo+3}{reset}, in {magenta}foo{reset}', - f' | {red}1 {reset}{boldr}/{reset}{red} 0{reset}', - f' | {red}~~{reset}{boldr}^{reset}{red}~~{reset}', - f" | exceptions = [ZeroDivisionError('division by zero')]", - f' | {boldm}ZeroDivisionError{reset}: {magenta}division by zero{reset}', - f' +------------------------------------'] - self.assertEqual(actual, expected) + def expected(t, m, fn, l, f, E, e, z): + return [ + f" + Exception Group Traceback (most recent call last):", + f' | File {fn}"{__file__}"{z}, line {l}{lno_foo+9}{z}, in {f}test_colorized_traceback_from_exception_group{z}', + f' | {e}foo{z}{E}(){z}', + f' | {e}~~~{z}{E}^^{z}', + f" | e = ExceptionGroup('test', [ZeroDivisionError('division by zero')])", + f" | foo = {foo}", + f' | self = <{__name__}.TestColorizedTraceback testMethod=test_colorized_traceback_from_exception_group>', + f' | File {fn}"{__file__}"{z}, line {l}{lno_foo+6}{z}, in {f}foo{z}', + f' | raise ExceptionGroup("test", exceptions)', + f" | exceptions = [ZeroDivisionError('division by zero')]", + f' | {t}ExceptionGroup{z}: {m}test (1 sub-exception){z}', + f' +-+---------------- 1 ----------------', + f' | Traceback (most recent call last):', + f' | File {fn}"{__file__}"{z}, line {l}{lno_foo+3}{z}, in {f}foo{z}', + f' | {e}1 {z}{E}/{z}{e} 0{z}', + f' | {e}~~{z}{E}^{z}{e}~~{z}', + f" | exceptions = [ZeroDivisionError('division by zero')]", + f' | {t}ZeroDivisionError{z}: {m}division by zero{z}', + f' +------------------------------------', + ] + self.assertEqual(actual, expected(**colors)) if __name__ == "__main__": unittest.main() diff --git a/Lib/traceback.py b/Lib/traceback.py index 16ba7fc2ee86fb..a86ec4c008e405 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -10,9 +10,10 @@ import keyword import tokenize import io -from contextlib import suppress import _colorize -from _colorize import ANSIColors + +from contextlib import suppress +from _colorize import get_theme, theme_no_color __all__ = ['extract_stack', 'extract_tb', 'format_exception', 'format_exception_only', 'format_list', 'format_stack', @@ -186,16 +187,11 @@ def format_exception_only(exc, /, value=_sentinel, *, show_group=False, **kwargs def _format_final_exc_line(etype, value, *, insert_final_newline=True, colorize=False): valuestr = _safe_string(value, 'exception') end_char = "\n" if insert_final_newline else "" - if colorize: - if value is None or not valuestr: - line = f"{ANSIColors.BOLD_MAGENTA}{etype}{ANSIColors.RESET}{end_char}" - else: - line = f"{ANSIColors.BOLD_MAGENTA}{etype}{ANSIColors.RESET}: {ANSIColors.MAGENTA}{valuestr}{ANSIColors.RESET}{end_char}" + theme = (theme_no_color if not colorize else get_theme()).traceback + if value is None or not valuestr: + line = f"{theme.type}{etype}{theme.reset}{end_char}" else: - if value is None or not valuestr: - line = f"{etype}{end_char}" - else: - line = f"{etype}: {valuestr}{end_char}" + line = f"{theme.type}{etype}{theme.reset}: {theme.message}{valuestr}{theme.reset}{end_char}" return line @@ -538,22 +534,21 @@ def format_frame_summary(self, frame_summary, **kwargs): filename = frame_summary.filename if frame_summary.filename.startswith("-"): filename = "" - if colorize: - row.append(' File {}"{}"{}, line {}{}{}, in {}{}{}\n'.format( - ANSIColors.MAGENTA, - filename, - ANSIColors.RESET, - ANSIColors.MAGENTA, - frame_summary.lineno, - ANSIColors.RESET, - ANSIColors.MAGENTA, - frame_summary.name, - ANSIColors.RESET, - ) + + theme = (theme_no_color if not colorize else get_theme()).traceback + row.append( + ' File {}"{}"{}, line {}{}{}, in {}{}{}\n'.format( + theme.filename, + filename, + theme.reset, + theme.line_no, + frame_summary.lineno, + theme.reset, + theme.frame, + frame_summary.name, + theme.reset, ) - else: - row.append(' File "{}", line {}, in {}\n'.format( - filename, frame_summary.lineno, frame_summary.name)) + ) if frame_summary._dedented_lines and frame_summary._dedented_lines.strip(): if ( frame_summary.colno is None or @@ -672,11 +667,11 @@ def output_line(lineno): for color, group in itertools.groupby(itertools.zip_longest(line, carets, fillvalue=""), key=lambda x: x[1]): caret_group = list(group) if color == "^": - colorized_line_parts.append(ANSIColors.BOLD_RED + "".join(char for char, _ in caret_group) + ANSIColors.RESET) - colorized_carets_parts.append(ANSIColors.BOLD_RED + "".join(caret for _, caret in caret_group) + ANSIColors.RESET) + colorized_line_parts.append(theme.error_highlight + "".join(char for char, _ in caret_group) + theme.reset) + colorized_carets_parts.append(theme.error_highlight + "".join(caret for _, caret in caret_group) + theme.reset) elif color == "~": - colorized_line_parts.append(ANSIColors.RED + "".join(char for char, _ in caret_group) + ANSIColors.RESET) - colorized_carets_parts.append(ANSIColors.RED + "".join(caret for _, caret in caret_group) + ANSIColors.RESET) + colorized_line_parts.append(theme.error_range + "".join(char for char, _ in caret_group) + theme.reset) + colorized_carets_parts.append(theme.error_range + "".join(caret for _, caret in caret_group) + theme.reset) else: colorized_line_parts.append("".join(char for char, _ in caret_group)) colorized_carets_parts.append("".join(caret for _, caret in caret_group)) @@ -1378,20 +1373,17 @@ def _format_syntax_error(self, stype, **kwargs): """Format SyntaxError exceptions (internal helper).""" # Show exactly where the problem was found. colorize = kwargs.get("colorize", False) + theme = (theme_no_color if not colorize else get_theme()).traceback filename_suffix = '' if self.lineno is not None: - if colorize: - yield ' File {}"{}"{}, line {}{}{}\n'.format( - ANSIColors.MAGENTA, - self.filename or "", - ANSIColors.RESET, - ANSIColors.MAGENTA, - self.lineno, - ANSIColors.RESET, - ) - else: - yield ' File "{}", line {}\n'.format( - self.filename or "", self.lineno) + yield ' File {}"{}"{}, line {}{}{}\n'.format( + theme.filename, + self.filename or "", + theme.reset, + theme.line_no, + self.lineno, + theme.reset, + ) elif self.filename is not None: filename_suffix = ' ({})'.format(self.filename) @@ -1441,11 +1433,11 @@ def _format_syntax_error(self, stype, **kwargs): # colorize from colno to end_colno ltext = ( ltext[:colno] + - ANSIColors.BOLD_RED + ltext[colno:end_colno] + ANSIColors.RESET + + theme.error_highlight + ltext[colno:end_colno] + theme.reset + ltext[end_colno:] ) - start_color = ANSIColors.BOLD_RED - end_color = ANSIColors.RESET + start_color = theme.error_highlight + end_color = theme.reset yield ' {}\n'.format(ltext) yield ' {}{}{}{}\n'.format( "".join(caretspace), @@ -1456,17 +1448,15 @@ def _format_syntax_error(self, stype, **kwargs): else: yield ' {}\n'.format(ltext) msg = self.msg or "" - if colorize: - yield "{}{}{}: {}{}{}{}\n".format( - ANSIColors.BOLD_MAGENTA, - stype, - ANSIColors.RESET, - ANSIColors.MAGENTA, - msg, - ANSIColors.RESET, - filename_suffix) - else: - yield "{}: {}{}\n".format(stype, msg, filename_suffix) + yield "{}{}{}: {}{}{}{}\n".format( + theme.type, + stype, + theme.reset, + theme.message, + msg, + theme.reset, + filename_suffix, + ) def format(self, *, chain=True, _ctx=None, **kwargs): """Format the exception. From b29c9b9ab4909e14f5d87bdc577da9f573466ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 3 May 2025 19:26:09 +0200 Subject: [PATCH 04/19] Fix WASI test failures --- Lib/_colorize.py | 9 +++++++-- Lib/_pyrepl/reader.py | 6 +----- Lib/traceback.py | 17 ++++++++++++----- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 5942fc6bdc34e2..ecdffee720e450 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -213,8 +213,13 @@ def can_colorize(*, file: IO[str] | IO[bytes] | None = None) -> bool: theme_no_color = default_theme.no_colors() -def get_theme(*, tty_file: IO[str] | IO[bytes] | None = None) -> Theme: - if can_colorize(file=tty_file): +def get_theme( + *, + tty_file: IO[str] | IO[bytes] | None = None, + force_color: bool = False, + force_no_color: bool = False, +) -> Theme: + if force_color or (not force_no_color and can_colorize(file=tty_file)): return _theme return theme_no_color diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 4686eeb5026acd..0ebd9162eca4bb 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -492,11 +492,7 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str: if self.can_colorize: t = THEME() - prompt = ( - f"{t.prompt}" - f"{prompt}" - f"{t.reset}" - ) + prompt = f"{t.prompt}{prompt}{t.reset}" return prompt def push_input_trans(self, itrans: input.KeymapTranslator) -> None: diff --git a/Lib/traceback.py b/Lib/traceback.py index a86ec4c008e405..17b082eced6f05 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -13,7 +13,6 @@ import _colorize from contextlib import suppress -from _colorize import get_theme, theme_no_color __all__ = ['extract_stack', 'extract_tb', 'format_exception', 'format_exception_only', 'format_list', 'format_stack', @@ -187,7 +186,10 @@ def format_exception_only(exc, /, value=_sentinel, *, show_group=False, **kwargs def _format_final_exc_line(etype, value, *, insert_final_newline=True, colorize=False): valuestr = _safe_string(value, 'exception') end_char = "\n" if insert_final_newline else "" - theme = (theme_no_color if not colorize else get_theme()).traceback + if colorize: + theme = _colorize.get_theme(force_color=True).traceback + else: + theme = _colorize.get_theme(force_no_color=True).traceback if value is None or not valuestr: line = f"{theme.type}{etype}{theme.reset}{end_char}" else: @@ -534,8 +536,10 @@ def format_frame_summary(self, frame_summary, **kwargs): filename = frame_summary.filename if frame_summary.filename.startswith("-"): filename = "" - - theme = (theme_no_color if not colorize else get_theme()).traceback + if colorize: + theme = _colorize.get_theme(force_color=True).traceback + else: + theme = _colorize.get_theme(force_no_color=True).traceback row.append( ' File {}"{}"{}, line {}{}{}, in {}{}{}\n'.format( theme.filename, @@ -1373,7 +1377,10 @@ def _format_syntax_error(self, stype, **kwargs): """Format SyntaxError exceptions (internal helper).""" # Show exactly where the problem was found. colorize = kwargs.get("colorize", False) - theme = (theme_no_color if not colorize else get_theme()).traceback + if colorize: + theme = _colorize.get_theme(force_color=True).traceback + else: + theme = _colorize.get_theme(force_no_color=True).traceback filename_suffix = '' if self.lineno is not None: yield ' File {}"{}"{}, line {}{}{}\n'.format( From ca349392c8ae12f5d6584f31af77af65145dc096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sat, 3 May 2025 19:28:17 +0200 Subject: [PATCH 05/19] Apply suggestions from code review Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/_colorize.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index ecdffee720e450..f8b2ff13b2fb66 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -81,20 +81,20 @@ class ThemeSection(Mapping[str, str]): def __post_init__(self) -> None: name_to_value = {} - for color_name, color_field in self.__dataclass_fields__.items(): + 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__) def copy_with(self, **kwargs: str) -> Self: color_state: dict[str, str] = {} - for color_name, color_field in self.__dataclass_fields__.items(): + for color_name in self.__dataclass_fields__: color_state[color_name] = getattr(self, color_name) color_state.update(kwargs) return type(self)(**color_state) def no_colors(self) -> Self: color_state: dict[str, str] = {} - for color_name, color_field in self.__dataclass_fields__.items(): + for color_name in self.__dataclass_fields__: color_state[color_name] = "" return type(self)(**color_state) @@ -228,7 +228,6 @@ def set_theme(t: Theme) -> None: global _theme _theme = t - return set_theme(default_theme) From 5c5c3e1136b761b913f8c6a4ba66b3dd20d3c619 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sat, 3 May 2025 22:10:57 +0300 Subject: [PATCH 06/19] Add theming to unittest --- Lib/_colorize.py | 10 +++++ Lib/unittest/runner.py | 89 +++++++++++++++++++----------------------- 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index f8b2ff13b2fb66..9702cdb494737b 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -134,10 +134,20 @@ class Traceback(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=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 + + @dataclass(frozen=True) class Theme: repl: REPL = field(default_factory=REPL) traceback: Traceback = field(default_factory=Traceback) + unittest: Unittest = field(default_factory=Unittest) def copy_with( self, diff --git a/Lib/unittest/runner.py b/Lib/unittest/runner.py index eb0234a2617680..5f22d91aebd05f 100644 --- a/Lib/unittest/runner.py +++ b/Lib/unittest/runner.py @@ -4,7 +4,7 @@ import time import warnings -from _colorize import get_colors +from _colorize import get_theme from . import result from .case import _SubTest @@ -45,7 +45,7 @@ def __init__(self, stream, descriptions, verbosity, *, durations=None): self.showAll = verbosity > 1 self.dots = verbosity == 1 self.descriptions = descriptions - self._ansi = get_colors(file=stream) + self._theme = get_theme(tty_file=stream).unittest self._newline = True self.durations = durations @@ -79,101 +79,99 @@ def _write_status(self, test, status): def addSubTest(self, test, subtest, err): if err is not None: - red, reset = self._ansi.RED, self._ansi.RESET + t = self._theme if self.showAll: if issubclass(err[0], subtest.failureException): - self._write_status(subtest, f"{red}FAIL{reset}") + self._write_status(subtest, f"{t.fail}FAIL{t.reset}") else: - self._write_status(subtest, f"{red}ERROR{reset}") + self._write_status(subtest, f"{t.fail}ERROR{t.reset}") elif self.dots: if issubclass(err[0], subtest.failureException): - self.stream.write(f"{red}F{reset}") + self.stream.write(f"{t.fail}F{t.reset}") else: - self.stream.write(f"{red}E{reset}") + self.stream.write(f"{t.fail}E{t.reset}") self.stream.flush() super(TextTestResult, self).addSubTest(test, subtest, err) def addSuccess(self, test): super(TextTestResult, self).addSuccess(test) - green, reset = self._ansi.GREEN, self._ansi.RESET + t = self._theme if self.showAll: - self._write_status(test, f"{green}ok{reset}") + self._write_status(test, f"{t.passed}ok{t.reset}") elif self.dots: - self.stream.write(f"{green}.{reset}") + self.stream.write(f"{t.passed}.{t.reset}") self.stream.flush() def addError(self, test, err): super(TextTestResult, self).addError(test, err) - red, reset = self._ansi.RED, self._ansi.RESET + t = self._theme if self.showAll: - self._write_status(test, f"{red}ERROR{reset}") + self._write_status(test, f"{t.fail}ERROR{t.reset}") elif self.dots: - self.stream.write(f"{red}E{reset}") + self.stream.write(f"{t.fail}E{t.reset}") self.stream.flush() def addFailure(self, test, err): super(TextTestResult, self).addFailure(test, err) - red, reset = self._ansi.RED, self._ansi.RESET + t = self._theme if self.showAll: - self._write_status(test, f"{red}FAIL{reset}") + self._write_status(test, f"{t.fail}FAIL{t.reset}") elif self.dots: - self.stream.write(f"{red}F{reset}") + self.stream.write(f"{t.fail}F{t.reset}") self.stream.flush() def addSkip(self, test, reason): super(TextTestResult, self).addSkip(test, reason) - yellow, reset = self._ansi.YELLOW, self._ansi.RESET + t = self._theme if self.showAll: - self._write_status(test, f"{yellow}skipped{reset} {reason!r}") + self._write_status(test, f"{t.warn}skipped{t.reset} {reason!r}") elif self.dots: - self.stream.write(f"{yellow}s{reset}") + self.stream.write(f"{t.warn}s{t.reset}") self.stream.flush() def addExpectedFailure(self, test, err): super(TextTestResult, self).addExpectedFailure(test, err) - yellow, reset = self._ansi.YELLOW, self._ansi.RESET + t = self._theme if self.showAll: - self.stream.writeln(f"{yellow}expected failure{reset}") + self.stream.writeln(f"{t.warn}expected failure{t.reset}") self.stream.flush() elif self.dots: - self.stream.write(f"{yellow}x{reset}") + self.stream.write(f"{t.warn}x{t.reset}") self.stream.flush() def addUnexpectedSuccess(self, test): super(TextTestResult, self).addUnexpectedSuccess(test) - red, reset = self._ansi.RED, self._ansi.RESET + t = self._theme if self.showAll: - self.stream.writeln(f"{red}unexpected success{reset}") + self.stream.writeln(f"{t.fail}unexpected success{t.reset}") self.stream.flush() elif self.dots: - self.stream.write(f"{red}u{reset}") + self.stream.write(f"{t.fail}u{t.reset}") self.stream.flush() def printErrors(self): - bold_red = self._ansi.BOLD_RED - red = self._ansi.RED - reset = self._ansi.RESET + t = self._theme if self.dots or self.showAll: self.stream.writeln() self.stream.flush() - self.printErrorList(f"{red}ERROR{reset}", self.errors) - self.printErrorList(f"{red}FAIL{reset}", self.failures) + self.printErrorList(f"{t.fail}ERROR{t.reset}", self.errors) + self.printErrorList(f"{t.fail}FAIL{t.reset}", self.failures) unexpectedSuccesses = getattr(self, "unexpectedSuccesses", ()) if unexpectedSuccesses: self.stream.writeln(self.separator1) for test in unexpectedSuccesses: self.stream.writeln( - f"{red}UNEXPECTED SUCCESS{bold_red}: " - f"{self.getDescription(test)}{reset}" + f"{t.fail}UNEXPECTED SUCCESS{t.fail_info}: " + f"{self.getDescription(test)}{t.reset}" ) self.stream.flush() def printErrorList(self, flavour, errors): - bold_red, reset = self._ansi.BOLD_RED, self._ansi.RESET + t = self._theme for test, err in errors: self.stream.writeln(self.separator1) self.stream.writeln( - f"{flavour}{bold_red}: {self.getDescription(test)}{reset}" + f"{flavour}{t.fail_info}: {self.getDescription(test)}{t.reset}" ) self.stream.writeln(self.separator2) self.stream.writeln("%s" % err) @@ -286,31 +284,26 @@ def run(self, test): expected_fails, unexpected_successes, skipped = results infos = [] - ansi = get_colors(file=self.stream) - bold_red = ansi.BOLD_RED - green = ansi.GREEN - red = ansi.RED - reset = ansi.RESET - yellow = ansi.YELLOW + t = get_theme(tty_file=self.stream).unittest if not result.wasSuccessful(): - self.stream.write(f"{bold_red}FAILED{reset}") + self.stream.write(f"{t.fail_info}FAILED{t.reset}") failed, errored = len(result.failures), len(result.errors) if failed: - infos.append(f"{bold_red}failures={failed}{reset}") + infos.append(f"{t.fail_info}failures={failed}{t.reset}") if errored: - infos.append(f"{bold_red}errors={errored}{reset}") + infos.append(f"{t.fail_info}errors={errored}{t.reset}") elif run == 0 and not skipped: - self.stream.write(f"{yellow}NO TESTS RAN{reset}") + self.stream.write(f"{t.warn}NO TESTS RAN{t.reset}") else: - self.stream.write(f"{green}OK{reset}") + self.stream.write(f"{t.passed}OK{t.reset}") if skipped: - infos.append(f"{yellow}skipped={skipped}{reset}") + infos.append(f"{t.warn}skipped={skipped}{t.reset}") if expected_fails: - infos.append(f"{yellow}expected failures={expected_fails}{reset}") + infos.append(f"{t.warn}expected failures={expected_fails}{t.reset}") if unexpected_successes: infos.append( - f"{red}unexpected successes={unexpected_successes}{reset}" + f"{t.fail}unexpected successes={unexpected_successes}{t.reset}" ) if infos: self.stream.writeln(" (%s)" % (", ".join(infos),)) From f449e7b29ec8f10b0109f01836796eb74a64816c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sun, 4 May 2025 19:30:16 +0200 Subject: [PATCH 07/19] Add missing plumbing for _colorize.Unittest --- Lib/_colorize.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 9702cdb494737b..158c198de01834 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -154,16 +154,19 @@ def copy_with( *, repl: REPL | None = None, traceback: Traceback | None = None, + unittest: Unittest | None = None, ) -> Self: return type(self)( repl=repl or self.repl, traceback=traceback or self.traceback, + unittest=unittest or self.unittest, ) def no_colors(self) -> Self: return type(self)( repl=self.repl.no_colors(), traceback=self.traceback.no_colors(), + unittest=self.unittest.no_colors(), ) From 8da314f9778119dc80b409d0eec39dc134088060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sun, 4 May 2025 19:30:44 +0200 Subject: [PATCH 08/19] Adapt tests to theming when executed with -j --- Lib/test/support/__init__.py | 37 ++++++++++++++++---- Lib/test/test_pyrepl/support.py | 3 -- Lib/test/test_pyrepl/test_reader.py | 33 +++++++++-------- Lib/test/test_pyrepl/test_unix_console.py | 12 +++---- Lib/test/test_pyrepl/test_windows_console.py | 4 ++- 5 files changed, 56 insertions(+), 33 deletions(-) diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 24984ad81fff99..23582c58c0a00b 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -2855,36 +2855,59 @@ def is_slot_wrapper(name, value): @contextlib.contextmanager -def no_color(): +def force_color(color: bool): import _colorize from .os_helper import EnvironmentVarGuard with ( - swap_attr(_colorize, "can_colorize", lambda file=None: False), + swap_attr(_colorize, "can_colorize", lambda file=None: color), EnvironmentVarGuard() as env, ): env.unset("FORCE_COLOR", "NO_COLOR", "PYTHON_COLORS") - env.set("NO_COLOR", "1") + env.set("FORCE_COLOR" if color else "NO_COLOR", "1") yield +def force_colorized(func): + """Force the terminal to be colorized.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + with force_color(True): + return func(*args, **kwargs) + return wrapper + + def force_not_colorized(func): - """Force the terminal not to be colorized.""" + """Force the terminal NOT to be colorized.""" @functools.wraps(func) def wrapper(*args, **kwargs): - with no_color(): + with force_color(False): return func(*args, **kwargs) return wrapper +def force_colorized_test_class(cls): + """Force the terminal to be colorized for the entire test class.""" + original_setUpClass = cls.setUpClass + + @classmethod + @functools.wraps(cls.setUpClass) + def new_setUpClass(cls): + cls.enterClassContext(force_color(True)) + original_setUpClass() + + cls.setUpClass = new_setUpClass + return cls + + def force_not_colorized_test_class(cls): - """Force the terminal not to be colorized for the entire test class.""" + """Force the terminal NOT to be colorized for the entire test class.""" original_setUpClass = cls.setUpClass @classmethod @functools.wraps(cls.setUpClass) def new_setUpClass(cls): - cls.enterClassContext(no_color()) + cls.enterClassContext(force_color(False)) original_setUpClass() cls.setUpClass = new_setUpClass diff --git a/Lib/test/test_pyrepl/support.py b/Lib/test/test_pyrepl/support.py index 3692e164cb9254..4f7f9d77933336 100644 --- a/Lib/test/test_pyrepl/support.py +++ b/Lib/test/test_pyrepl/support.py @@ -113,9 +113,6 @@ def handle_all_events( prepare_console=partial(prepare_console, width=10), ) -reader_no_colors = partial(prepare_reader, can_colorize=False) -reader_force_colors = partial(prepare_reader, can_colorize=True) - class FakeConsole(Console): def __init__(self, events, encoding="utf-8") -> None: diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 2de651574189e6..f705be11e753e5 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -4,11 +4,11 @@ from textwrap import dedent from unittest import TestCase from unittest.mock import MagicMock +from test.support import force_colorized_test_class, force_not_colorized_test_class from .support import handle_all_events, handle_events_narrow_console from .support import ScreenEqualMixin, code_to_events -from .support import prepare_console, reader_force_colors -from .support import reader_no_colors as prepare_reader +from .support import prepare_reader, prepare_console from _pyrepl.console import Event from _pyrepl.reader import Reader from _colorize import default_theme @@ -18,6 +18,7 @@ colors = {overrides.get(k, k[0].lower()): v for k, v in default_theme.repl.items()} +@force_not_colorized_test_class class TestReader(ScreenEqualMixin, TestCase): def test_calc_screen_wrap_simple(self): events = code_to_events(10 * "a") @@ -127,13 +128,6 @@ def test_setpos_for_xy_simple(self): reader.setpos_from_xy(0, 0) self.assertEqual(reader.pos, 0) - def test_control_characters(self): - code = 'flag = "🏳️‍🌈"' - events = code_to_events(code) - reader, _ = handle_all_events(events, prepare_reader=reader_force_colors) - self.assert_screen_equal(reader, 'flag = "🏳️\\u200d🌈"', clean=True) - self.assert_screen_equal(reader, 'flag {o}={z} {s}"🏳️\\u200d🌈"{z}'.format(**colors)) - def test_setpos_from_xy_multiple_lines(self): # fmt: off code = ( @@ -364,6 +358,8 @@ def test_setpos_from_xy_for_non_printing_char(self): reader.setpos_from_xy(8, 0) self.assertEqual(reader.pos, 7) +@force_colorized_test_class +class TestReaderInColor(ScreenEqualMixin, TestCase): def test_syntax_highlighting_basic(self): code = dedent( """\ @@ -403,7 +399,7 @@ def funct(case: str = sys.platform) -> None: ) expected_sync = expected.format(a="", **colors) events = code_to_events(code) - reader, _ = handle_all_events(events, prepare_reader=reader_force_colors) + reader, _ = handle_all_events(events) self.assert_screen_equal(reader, code, clean=True) self.assert_screen_equal(reader, expected_sync) self.assertEqual(reader.pos, 2**7 + 2**8) @@ -416,7 +412,7 @@ def funct(case: str = sys.platform) -> None: [Event(evt="key", data="up", raw=bytearray(b"\x1bOA"))] * 13, code_to_events("async "), ) - reader, _ = handle_all_events(more_events, prepare_reader=reader_force_colors) + reader, _ = handle_all_events(more_events) self.assert_screen_equal(reader, expected_async) self.assertEqual(reader.pos, 21) self.assertEqual(reader.cxy, (6, 1)) @@ -433,7 +429,7 @@ def unfinished_function(arg: str = "still typing """ ).format(**colors) events = code_to_events(code) - reader, _ = handle_all_events(events, prepare_reader=reader_force_colors) + reader, _ = handle_all_events(events) self.assert_screen_equal(reader, code, clean=True) self.assert_screen_equal(reader, expected) @@ -451,7 +447,7 @@ def unfinished_function( """ ).format(**colors) events = code_to_events(code) - reader, _ = handle_all_events(events, prepare_reader=reader_force_colors) + reader, _ = handle_all_events(events) self.assert_screen_equal(reader, code, clean=True) self.assert_screen_equal(reader, expected) @@ -471,7 +467,7 @@ def unfinished_function(): """ ).format(**colors) events = code_to_events(code) - reader, _ = handle_all_events(events, prepare_reader=reader_force_colors) + reader, _ = handle_all_events(events) self.assert_screen_equal(reader, code, clean=True) self.assert_screen_equal(reader, expected) @@ -497,6 +493,13 @@ def unfinished_function(): """ ).format(OB="{", CB="}", **colors) events = code_to_events(code) - reader, _ = handle_all_events(events, prepare_reader=reader_force_colors) + reader, _ = handle_all_events(events) self.assert_screen_equal(reader, code, clean=True) self.assert_screen_equal(reader, expected) + + def test_control_characters(self): + code = 'flag = "🏳️‍🌈"' + events = code_to_events(code) + reader, _ = handle_all_events(events) + self.assert_screen_equal(reader, 'flag = "🏳️\\u200d🌈"', clean=True) + self.assert_screen_equal(reader, 'flag {o}={z} {s}"🏳️\\u200d🌈"{z}'.format(**colors)) \ No newline at end of file diff --git a/Lib/test/test_pyrepl/test_unix_console.py b/Lib/test/test_pyrepl/test_unix_console.py index 7acb84a94f7224..c447b310c49a06 100644 --- a/Lib/test/test_pyrepl/test_unix_console.py +++ b/Lib/test/test_pyrepl/test_unix_console.py @@ -3,11 +3,12 @@ import sys import unittest from functools import partial -from test.support import os_helper +from test.support import os_helper, force_not_colorized_test_class + from unittest import TestCase from unittest.mock import MagicMock, call, patch, ANY -from .support import handle_all_events, code_to_events, reader_no_colors +from .support import handle_all_events, code_to_events try: from _pyrepl.console import Event @@ -33,12 +34,10 @@ def unix_console(events, **kwargs): handle_events_unix_console = partial( handle_all_events, - prepare_reader=reader_no_colors, prepare_console=unix_console, ) handle_events_narrow_unix_console = partial( handle_all_events, - prepare_reader=reader_no_colors, prepare_console=partial(unix_console, width=5), ) handle_events_short_unix_console = partial( @@ -120,6 +119,7 @@ def unix_console(events, **kwargs): ) @patch("termios.tcsetattr", lambda a, b, c: None) @patch("os.write") +@force_not_colorized_test_class class TestConsole(TestCase): def test_simple_addition(self, _os_write): code = "12+34" @@ -255,9 +255,7 @@ def test_resize_bigger_on_multiline_function(self, _os_write): # fmt: on events = itertools.chain(code_to_events(code)) - reader, console = handle_events_short_unix_console( - events, prepare_reader=reader_no_colors - ) + reader, console = handle_events_short_unix_console(events) console.height = 2 console.getheightwidth = MagicMock(lambda _: (2, 80)) diff --git a/Lib/test/test_pyrepl/test_windows_console.py b/Lib/test/test_pyrepl/test_windows_console.py index e95fec46a851ee..d1f8a64c14087e 100644 --- a/Lib/test/test_pyrepl/test_windows_console.py +++ b/Lib/test/test_pyrepl/test_windows_console.py @@ -7,12 +7,13 @@ import itertools from functools import partial +from test.support import force_not_colorized_test_class from typing import Iterable from unittest import TestCase from unittest.mock import MagicMock, call from .support import handle_all_events, code_to_events -from .support import reader_no_colors as default_prepare_reader +from .support import prepare_reader as default_prepare_reader try: from _pyrepl.console import Event, Console @@ -28,6 +29,7 @@ pass +@force_not_colorized_test_class class WindowsConsoleTests(TestCase): def console(self, events, **kwargs) -> Console: console = WindowsConsole() From 65d3a78851b2deee1acf6466574ebd9a0dc8f9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sun, 4 May 2025 19:44:27 +0200 Subject: [PATCH 09/19] Fix. lint. --- Lib/test/test_pyrepl/test_reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index f705be11e753e5..407ba8751f67fd 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -502,4 +502,4 @@ def test_control_characters(self): events = code_to_events(code) reader, _ = handle_all_events(events) self.assert_screen_equal(reader, 'flag = "🏳️\\u200d🌈"', clean=True) - self.assert_screen_equal(reader, 'flag {o}={z} {s}"🏳️\\u200d🌈"{z}'.format(**colors)) \ No newline at end of file + self.assert_screen_equal(reader, 'flag {o}={z} {s}"🏳️\\u200d🌈"{z}'.format(**colors)) From 0866fd8a0f22a9df26532625bda4bf0c2be4272f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sun, 4 May 2025 20:14:07 +0200 Subject: [PATCH 10/19] Add blurb and some docstrings --- Lib/_colorize.py | 55 +++++++++++++++++++ ...-05-04-19-46-14.gh-issue-133346.nRXi4f.rst | 1 + 2 files changed, 56 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-05-04-19-46-14.gh-issue-133346.nRXi4f.rst diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 158c198de01834..b7f201ba5d4991 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -75,7 +75,34 @@ class ANSIColors: setattr(NoColors, attr, "") +# +# Experimental theming support (see gh-133346) +# + +# - create a theme by copying an existing `Theme` with one or more sections +# replaced, using `default_theme.copy_with()`; +# - create a theme section by copying an existing `ThemeSection` with one or +# more colors replaced, using for example `default_theme.repl.copy_with()`; +# - create a theme from scratch by instantiating a `Theme` data class with +# the required sections (with are also dataclass instances). +# +# Then call `_colorize.set_theme(your_theme)` to set it. +# +# Put your theme configuration in $PYTHONSTARTUP for the interactive shell, +# or sitecustomize.py in your virtual environment or Python installation for +# other uses. Your applications can call `_colorize.set_theme()` too. +# +# Note that thanks to the dataclasses providing default values for all fields, +# creating a new theme or theme section from scratch is possible without +# specifying all keys. + + class ThemeSection(Mapping[str, str]): + """A mixin/base class for theme sections. + + It enables dictionary access to a section, as well as implements convenience + methods. + """ __dataclass_fields__: ClassVar[dict[str, Field[str]]] _name_to_value: Callable[[str], str] @@ -145,6 +172,11 @@ class Unittest(ThemeSection): @dataclass(frozen=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. + """ repl: REPL = field(default_factory=REPL) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) @@ -156,6 +188,11 @@ def copy_with( traceback: Traceback | None = None, unittest: Unittest | None = None, ) -> Self: + """Return a new Theme based on this instance with some sections replaced. + + Themes are immutable to protect against accidental modifications that + could lead to invalid terminal states. + """ return type(self)( repl=repl or self.repl, traceback=traceback or self.traceback, @@ -163,6 +200,12 @@ def copy_with( ) def no_colors(self) -> Self: + """Return a new Theme where colors in all sections are empty strings. + + This allows writing user code as if colors are always used. The color + fields will be ANSI color code strings when colorization is desired + and possible, and empty strings otherwise. + """ return type(self)( repl=self.repl.no_colors(), traceback=self.traceback.no_colors(), @@ -232,6 +275,18 @@ def get_theme( force_color: bool = False, force_no_color: bool = False, ) -> Theme: + """Returns the currently set theme, potentially in a zero-color variant. + + In cases where colorizing is not possible (see `can_colorize`), the returned + theme contains all empty strings in all color definitions. + See `Theme.no_colors()` for more information. + + It is recommended not to cache the result of this function for extended + periods of time because the user might influence theme selection by + the interactive shell, a debugger, or application-specific code. The + environment (including environment variable state and console configuration + on Windows) can also change in the course of the application life cycle. + """ if force_color or (not force_no_color and can_colorize(file=tty_file)): return _theme return theme_no_color diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-05-04-19-46-14.gh-issue-133346.nRXi4f.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-04-19-46-14.gh-issue-133346.nRXi4f.rst new file mode 100644 index 00000000000000..c49a1e71f881cf --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-05-04-19-46-14.gh-issue-133346.nRXi4f.rst @@ -0,0 +1 @@ +Added experimental color theming support to the ``_colorize`` module. From 965c7cf4bc008d1d42925f309376393569da054a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sun, 4 May 2025 20:27:48 +0200 Subject: [PATCH 11/19] FIX. LINT. --- Lib/_colorize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index b7f201ba5d4991..bfb4460b5383ea 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -173,7 +173,7 @@ class Unittest(ThemeSection): @dataclass(frozen=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. """ From 230d65886c6c017287081aaee94dfc9b22116aed Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 4 May 2025 14:23:07 +0300 Subject: [PATCH 12/19] Add theming to argparse --- Lib/_colorize.py | 20 ++++++++++++++ Lib/argparse.py | 68 +++++++++++++++++++++++------------------------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index bfb4460b5383ea..a34f1961cfdfdc 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -135,6 +135,22 @@ def __iter__(self) -> Iterator[str]: return iter(self.__dataclass_fields__) +@dataclass(frozen=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 + long_option: str = ANSIColors.BOLD_CYAN + short_option: str = ANSIColors.BOLD_GREEN + label: str = ANSIColors.BOLD_YELLOW + action: str = ANSIColors.BOLD_GREEN + reset: str = ANSIColors.RESET + + @dataclass(frozen=True) class REPL(ThemeSection): prompt: str = ANSIColors.BOLD_MAGENTA @@ -177,6 +193,7 @@ class Theme: When adding a new one, remember to also modify `copy_with` and `no_colors` below. """ + argparse: Argparse = field(default_factory=Argparse) repl: REPL = field(default_factory=REPL) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) @@ -184,6 +201,7 @@ class Theme: def copy_with( self, *, + argparse: Argparse | None = None, repl: REPL | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, @@ -194,6 +212,7 @@ def copy_with( could lead to invalid terminal states. """ return type(self)( + argparse=argparse or self.argparse, repl=repl or self.repl, traceback=traceback or self.traceback, unittest=unittest or self.unittest, @@ -207,6 +226,7 @@ def no_colors(self) -> Self: and possible, and empty strings otherwise. """ return type(self)( + argparse=self.argparse.no_colors(), repl=self.repl.no_colors(), traceback=self.traceback.no_colors(), unittest=self.unittest.no_colors(), diff --git a/Lib/argparse.py b/Lib/argparse.py index c0dcd0bbff063c..dfd7fea5fcf799 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -176,13 +176,13 @@ def __init__( width = shutil.get_terminal_size().columns width -= 2 - from _colorize import ANSIColors, NoColors, can_colorize, decolor + from _colorize import can_colorize, decolor, get_theme if color and can_colorize(): - self._ansi = ANSIColors() + self._theme = get_theme(force_color=True).argparse self._decolor = decolor else: - self._ansi = NoColors + self._theme = get_theme(force_no_color=True).argparse self._decolor = lambda text: text self._prefix_chars = prefix_chars @@ -237,14 +237,12 @@ def format_help(self): # add the heading if the section was non-empty if self.heading is not SUPPRESS and self.heading is not None: - bold_blue = self.formatter._ansi.BOLD_BLUE - reset = self.formatter._ansi.RESET - current_indent = self.formatter._current_indent heading_text = _('%(heading)s:') % dict(heading=self.heading) + t = self.formatter._theme heading = ( f'{" " * current_indent}' - f'{bold_blue}{heading_text}{reset}\n' + f'{t.heading}{heading_text}{t.reset}\n' ) else: heading = '' @@ -314,10 +312,7 @@ def _join_parts(self, part_strings): if part and part is not SUPPRESS]) def _format_usage(self, usage, actions, groups, prefix): - bold_blue = self._ansi.BOLD_BLUE - bold_magenta = self._ansi.BOLD_MAGENTA - magenta = self._ansi.MAGENTA - reset = self._ansi.RESET + t = self._theme if prefix is None: prefix = _('usage: ') @@ -325,15 +320,15 @@ def _format_usage(self, usage, actions, groups, prefix): # if usage is specified, use that if usage is not None: usage = ( - magenta + t.prog_extra + usage - % {"prog": f"{bold_magenta}{self._prog}{reset}{magenta}"} - + reset + % {"prog": f"{t.prog}{self._prog}{t.reset}{t.prog_extra}"} + + t.reset ) # if no optionals or positionals are available, usage is just prog elif usage is None and not actions: - usage = f"{bold_magenta}{self._prog}{reset}" + usage = f"{t.prog}{self._prog}{t.reset}" # if optionals and positionals are available, calculate usage elif usage is None: @@ -411,10 +406,10 @@ def get_lines(parts, indent, prefix=None): usage = '\n'.join(lines) usage = usage.removeprefix(prog) - usage = f"{bold_magenta}{prog}{reset}{usage}" + usage = f"{t.prog}{prog}{t.reset}{usage}" # prefix with 'usage:' - return f'{bold_blue}{prefix}{reset}{usage}\n\n' + return f'{t.usage}{prefix}{t.reset}{usage}\n\n' def _format_actions_usage(self, actions, groups): return ' '.join(self._get_actions_usage_parts(actions, groups)) @@ -452,10 +447,7 @@ def _get_actions_usage_parts(self, actions, groups): # collect all actions format strings parts = [] - cyan = self._ansi.CYAN - green = self._ansi.GREEN - yellow = self._ansi.YELLOW - reset = self._ansi.RESET + t = self._theme for action in actions: # suppressed arguments are marked with None @@ -465,7 +457,11 @@ def _get_actions_usage_parts(self, actions, groups): # produce all arg strings elif not action.option_strings: default = self._get_default_metavar_for_positional(action) - part = green + self._format_args(action, default) + reset + part = ( + t.summary_short_option + + self._format_args(action, default) + + t.reset + ) # if it's in a group, strip the outer [] if action in group_actions: @@ -481,9 +477,9 @@ def _get_actions_usage_parts(self, actions, groups): if action.nargs == 0: part = action.format_usage() if self._is_long_option(part): - part = f"{cyan}{part}{reset}" + part = f"{t.summary_long_option}{part}{t.reset}" elif self._is_short_option(part): - part = f"{green}{part}{reset}" + part = f"{t.summary_short_option}{part}{t.reset}" # if the Optional takes a value, format is: # -s ARGS or --long ARGS @@ -491,10 +487,13 @@ def _get_actions_usage_parts(self, actions, groups): default = self._get_default_metavar_for_optional(action) args_string = self._format_args(action, default) if self._is_long_option(option_string): - option_string = f"{cyan}{option_string}" + option_color = t.summary_long_option elif self._is_short_option(option_string): - option_string = f"{green}{option_string}" - part = f"{option_string} {yellow}{args_string}{reset}" + option_color = t.summary_short_option + part = ( + f"{option_color}{option_string} " + f"{t.summary_label}{args_string}{t.reset}" + ) # make it look optional if it's not required or in a group if not action.required and action not in group_actions: @@ -590,17 +589,14 @@ def _format_action(self, action): return self._join_parts(parts) def _format_action_invocation(self, action): - bold_green = self._ansi.BOLD_GREEN - bold_cyan = self._ansi.BOLD_CYAN - bold_yellow = self._ansi.BOLD_YELLOW - reset = self._ansi.RESET + t = self._theme if not action.option_strings: default = self._get_default_metavar_for_positional(action) return ( - bold_green + t.action + ' '.join(self._metavar_formatter(action, default)(1)) - + reset + + t.reset ) else: @@ -609,9 +605,9 @@ def color_option_strings(strings): parts = [] for s in strings: if self._is_long_option(s): - parts.append(f"{bold_cyan}{s}{reset}") + parts.append(f"{t.long_option}{s}{t.reset}") elif self._is_short_option(s): - parts.append(f"{bold_green}{s}{reset}") + parts.append(f"{t.short_option}{s}{t.reset}") else: parts.append(s) return parts @@ -628,7 +624,7 @@ def color_option_strings(strings): default = self._get_default_metavar_for_optional(action) option_strings = color_option_strings(action.option_strings) args_string = ( - f"{bold_yellow}{self._format_args(action, default)}{reset}" + f"{t.label}{self._format_args(action, default)}{t.reset}" ) return ', '.join(option_strings) + ' ' + args_string From c4808c85020d3514a86210957c1474ac1bd5c5ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Sun, 4 May 2025 23:25:44 +0200 Subject: [PATCH 13/19] Apply suggestions from code review Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/_colorize.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index a34f1961cfdfdc..c5822019f0d6c0 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -79,12 +79,12 @@ class ANSIColors: # Experimental theming support (see gh-133346) # -# - create a theme by copying an existing `Theme` with one or more sections +# - Create a theme by copying an existing `Theme` with one or more sections # replaced, using `default_theme.copy_with()`; # - create a theme section by copying an existing `ThemeSection` with one or # more colors replaced, using for example `default_theme.repl.copy_with()`; # - create a theme from scratch by instantiating a `Theme` data class with -# the required sections (with are also dataclass instances). +# the required sections (which are also dataclass instances). # # Then call `_colorize.set_theme(your_theme)` to set it. # From fd9c85cd48dfe8996938cbe28bbd1361dddf7152 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 4 May 2025 22:46:23 +0300 Subject: [PATCH 14/19] Update test_argparse to use theme not color and fix bug --- Lib/_colorize.py | 1 + Lib/argparse.py | 2 +- Lib/test/test_argparse.py | 34 +++++++++++++++++++--------------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index c5822019f0d6c0..4a227db524cfbb 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -144,6 +144,7 @@ class Argparse(ThemeSection): 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 diff --git a/Lib/argparse.py b/Lib/argparse.py index dfd7fea5fcf799..f13ac82dbc50b3 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -458,7 +458,7 @@ def _get_actions_usage_parts(self, actions, groups): elif not action.option_strings: default = self._get_default_metavar_for_positional(action) part = ( - t.summary_short_option + t.summary_action + self._format_args(action, default) + t.reset ) diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index c5a1f31aa520ae..5a6be1180c1a3e 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -7058,7 +7058,7 @@ def setUp(self): super().setUp() # Ensure color even if ran with NO_COLOR=1 _colorize.can_colorize = lambda *args, **kwargs: True - self.ansi = _colorize.ANSIColors() + self.theme = _colorize.get_theme(force_color=True).argparse def test_argparse_color(self): # Arrange: create a parser with a bit of everything @@ -7120,13 +7120,17 @@ def test_argparse_color(self): sub2 = subparsers.add_parser("sub2", deprecated=True, help="sub2 help") sub2.add_argument("--baz", choices=("X", "Y", "Z"), help="baz help") - heading = self.ansi.BOLD_BLUE - label, label_b = self.ansi.YELLOW, self.ansi.BOLD_YELLOW - long, long_b = self.ansi.CYAN, self.ansi.BOLD_CYAN - pos, pos_b = short, short_b = self.ansi.GREEN, self.ansi.BOLD_GREEN - sub = self.ansi.BOLD_GREEN - prog = self.ansi.BOLD_MAGENTA - reset = self.ansi.RESET + prog = self.theme.prog + heading = self.theme.heading + long = self.theme.summary_long_option + short = self.theme.summary_short_option + label = self.theme.summary_label + pos = self.theme.summary_action + long_b = self.theme.long_option + short_b = self.theme.short_option + label_b = self.theme.label + pos_b = self.theme.action + reset = self.theme.reset # Act help_text = parser.format_help() @@ -7171,9 +7175,9 @@ def test_argparse_color(self): {heading}subcommands:{reset} valid subcommands - {sub}{{sub1,sub2}}{reset} additional help - {sub}sub1{reset} sub1 help - {sub}sub2{reset} sub2 help + {pos_b}{{sub1,sub2}}{reset} additional help + {pos_b}sub1{reset} sub1 help + {pos_b}sub2{reset} sub2 help """ ), ) @@ -7187,10 +7191,10 @@ def test_argparse_color_usage(self): prog="PROG", usage="[prefix] %(prog)s [suffix]", ) - heading = self.ansi.BOLD_BLUE - prog = self.ansi.BOLD_MAGENTA - reset = self.ansi.RESET - usage = self.ansi.MAGENTA + heading = self.theme.heading + prog = self.theme.prog + reset = self.theme.reset + usage = self.theme.prog_extra # Act help_text = parser.format_help() From e67fda9862b53bf1ea3d2580233f3d0ae13119af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 5 May 2025 11:23:53 +0200 Subject: [PATCH 15/19] Rename REPL to Syntax now that pdb is using it, too; adapt pdb tests --- Lib/_colorize.py | 12 ++++++------ Lib/_pyrepl/utils.py | 2 +- Lib/asyncio/__main__.py | 2 +- Lib/pdb.py | 2 +- Lib/test/test_pdb.py | 7 +++---- Lib/test/test_pyrepl/test_reader.py | 2 +- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 4a227db524cfbb..8f32fda42f051a 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -82,7 +82,7 @@ class ANSIColors: # - Create a theme by copying an existing `Theme` with one or more sections # replaced, using `default_theme.copy_with()`; # - create a theme section by copying an existing `ThemeSection` with one or -# more colors replaced, using for example `default_theme.repl.copy_with()`; +# more colors replaced, using for example `default_theme.syntax.copy_with()`; # - create a theme from scratch by instantiating a `Theme` data class with # the required sections (which are also dataclass instances). # @@ -153,7 +153,7 @@ class Argparse(ThemeSection): @dataclass(frozen=True) -class REPL(ThemeSection): +class Syntax(ThemeSection): prompt: str = ANSIColors.BOLD_MAGENTA keyword: str = ANSIColors.BOLD_BLUE builtin: str = ANSIColors.CYAN @@ -195,7 +195,7 @@ class Theme: below. """ argparse: Argparse = field(default_factory=Argparse) - repl: REPL = field(default_factory=REPL) + syntax: Syntax = field(default_factory=Syntax) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) @@ -203,7 +203,7 @@ def copy_with( self, *, argparse: Argparse | None = None, - repl: REPL | None = None, + syntax: Syntax | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, ) -> Self: @@ -214,7 +214,7 @@ def copy_with( """ return type(self)( argparse=argparse or self.argparse, - repl=repl or self.repl, + syntax=syntax or self.syntax, traceback=traceback or self.traceback, unittest=unittest or self.unittest, ) @@ -228,7 +228,7 @@ def no_colors(self) -> Self: """ return type(self)( argparse=self.argparse.no_colors(), - repl=self.repl.no_colors(), + syntax=self.syntax.no_colors(), traceback=self.traceback.no_colors(), unittest=self.unittest.no_colors(), ) diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index 8d80ab93b28bcd..dd327d6990234c 100644 --- a/Lib/_pyrepl/utils.py +++ b/Lib/_pyrepl/utils.py @@ -25,7 +25,7 @@ def THEME(): # Not cached: the user can modify the theme inside the interactive session. - return _colorize.get_theme().repl + return _colorize.get_theme().syntax class Span(NamedTuple): diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index 0c23260ca30e07..d85a3269215272 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -104,7 +104,7 @@ def run(self): ps1 = getattr(sys, "ps1", ">>> ") if CAN_USE_PYREPL: - theme = get_theme().repl + theme = get_theme().syntax ps1 = f"{theme.prompt}{ps1}{theme.reset}" console.write(f"{ps1}import asyncio\n") diff --git a/Lib/pdb.py b/Lib/pdb.py index 195bfa557ef1e4..dd179d49142f2a 100644 --- a/Lib/pdb.py +++ b/Lib/pdb.py @@ -353,7 +353,7 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, skip=None, self._wait_for_mainpyfile = False self.tb_lineno = {} self.mode = mode - self.colorize = _colorize.can_colorize(file=stdout or sys.stdout) and colorize + self.colorize = colorize and _colorize.can_colorize(file=stdout or sys.stdout) # Try to load readline if it exists try: import readline diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py index 9223a7130d4e0d..8076a4b91d9d17 100644 --- a/Lib/test/test_pdb.py +++ b/Lib/test/test_pdb.py @@ -20,7 +20,7 @@ from contextlib import ExitStack, redirect_stdout from io import StringIO from test import support -from test.support import force_not_colorized, has_socket_support, os_helper +from test.support import has_socket_support, os_helper from test.support.import_helper import import_module from test.support.pty_helper import run_pty, FakeInput from test.support.script_helper import kill_python @@ -3743,7 +3743,6 @@ def start_pdb(): self.assertNotIn(b'Error', stdout, "Got an error running test script under PDB") - @force_not_colorized def test_issue16180(self): # A syntax error in the debuggee. script = "def f: pass\n" @@ -3757,7 +3756,6 @@ def test_issue16180(self): 'Fail to handle a syntax error in the debuggee.' .format(expected, stderr)) - @force_not_colorized def test_issue84583(self): # A syntax error from ast.literal_eval should not make pdb exit. script = "import ast; ast.literal_eval('')\n" @@ -4691,7 +4689,7 @@ def foo(): self.assertIn("42", stdout) -@unittest.skipUnless(_colorize.can_colorize(), "Test requires colorize") +@support.force_colorized_test_class class PdbTestColorize(unittest.TestCase): def setUp(self): self._original_can_colorize = _colorize.can_colorize @@ -4748,6 +4746,7 @@ def test_return_from_inline_mode_to_REPL(self): self.assertEqual(p.returncode, 0) +@support.force_not_colorized_test_class @support.requires_subprocess() class PdbTestReadline(unittest.TestCase): def setUpClass(): diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 407ba8751f67fd..4ee320a5a4dabb 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -15,7 +15,7 @@ overrides = {"reset": "z", "soft_keyword": "K"} -colors = {overrides.get(k, k[0].lower()): v for k, v in default_theme.repl.items()} +colors = {overrides.get(k, k[0].lower()): v for k, v in default_theme.syntax.items()} @force_not_colorized_test_class From 70ca1615327a7d0d74fd44465645a233269d3013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 5 May 2025 15:44:32 +0200 Subject: [PATCH 16/19] Bring json.tool into the theming fold --- Lib/json/tool.py | 41 ++++++++-------- Lib/test/test_json/test_tool.py | 85 ++++++++++++++++++--------------- 2 files changed, 69 insertions(+), 57 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 585583da8604ac..037bb0ceea0fb1 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -7,7 +7,7 @@ import json import re import sys -from _colorize import ANSIColors, can_colorize +from _colorize import get_theme, can_colorize # The string we are colorizing is valid JSON, @@ -17,27 +17,27 @@ _color_pattern = re.compile(r''' (?P"(\\.|[^"\\])*")(?=:) | (?P"(\\.|[^"\\])*") | + (?PNaN|-?Infinity|[0-9\-+.Ee]+) | (?Ptrue|false) | (?Pnull) ''', re.VERBOSE) - -_colors = { - 'key': ANSIColors.INTENSE_BLUE, - 'string': ANSIColors.BOLD_GREEN, - 'boolean': ANSIColors.BOLD_CYAN, - 'null': ANSIColors.BOLD_CYAN, +_group_to_theme_color = { + "key": "definition", + "string": "string", + "number": "number", + "boolean": "keyword", + "null": "keyword", } -def _replace_match_callback(match): - for key, color in _colors.items(): - if m := match.group(key): - return f"{color}{m}{ANSIColors.RESET}" - return match.group() - +def _colorize_json(json_str, theme): + def _replace_match_callback(match): + for group, color in _group_to_theme_color.items(): + if m := match.group(group): + return f"{theme[color]}{m}{theme.reset}" + return match.group() -def _colorize_json(json_str): return re.sub(_color_pattern, _replace_match_callback, json_str) @@ -100,13 +100,16 @@ def main(): else: outfile = open(options.outfile, 'w', encoding='utf-8') with outfile: - for obj in objs: - if can_colorize(file=outfile): + if can_colorize(file=outfile): + t = get_theme(tty_file=outfile).syntax + for obj in objs: json_str = json.dumps(obj, **dump_args) - outfile.write(_colorize_json(json_str)) - else: + outfile.write(_colorize_json(json_str, t)) + outfile.write('\n') + else: + for obj in objs: json.dump(obj, outfile, **dump_args) - outfile.write('\n') + outfile.write('\n') except ValueError as e: raise SystemExit(e) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index ba9c42f758e2b2..72cde3f0d6c1bd 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -6,9 +6,11 @@ import subprocess from test import support -from test.support import force_not_colorized, os_helper +from test.support import force_colorized, force_not_colorized, os_helper from test.support.script_helper import assert_python_ok +from _colorize import get_theme + @support.requires_subprocess() class TestMain(unittest.TestCase): @@ -246,34 +248,39 @@ def test_broken_pipe_error(self): proc.communicate(b'"{}"') self.assertEqual(proc.returncode, errno.EPIPE) + @force_colorized def test_colors(self): infile = os_helper.TESTFN self.addCleanup(os.remove, infile) + t = get_theme().syntax + ob = "{" + cb = "}" + cases = ( - ('{}', b'{}'), - ('[]', b'[]'), - ('null', b'\x1b[1;36mnull\x1b[0m'), - ('true', b'\x1b[1;36mtrue\x1b[0m'), - ('false', b'\x1b[1;36mfalse\x1b[0m'), - ('NaN', b'NaN'), - ('Infinity', b'Infinity'), - ('-Infinity', b'-Infinity'), - ('"foo"', b'\x1b[1;32m"foo"\x1b[0m'), - (r'" \"foo\" "', b'\x1b[1;32m" \\"foo\\" "\x1b[0m'), - ('"α"', b'\x1b[1;32m"\\u03b1"\x1b[0m'), - ('123', b'123'), - ('-1.2345e+23', b'-1.2345e+23'), + ('{}', '{}'), + ('[]', '[]'), + ('null', f'{t.keyword}null{t.reset}'), + ('true', f'{t.keyword}true{t.reset}'), + ('false', f'{t.keyword}false{t.reset}'), + ('NaN', f'{t.number}NaN{t.reset}'), + ('Infinity', f'{t.number}Infinity{t.reset}'), + ('-Infinity', f'{t.number}-Infinity{t.reset}'), + ('"foo"', f'{t.string}"foo"{t.reset}'), + (r'" \"foo\" "', f'{t.string}" \\"foo\\" "{t.reset}'), + ('"α"', f'{t.string}"\\u03b1"{t.reset}'), + ('123', f'{t.number}123{t.reset}'), + ('-1.2345e+23', f'{t.number}-1.2345e+23{t.reset}'), (r'{"\\": ""}', - b'''\ -{ - \x1b[94m"\\\\"\x1b[0m: \x1b[1;32m""\x1b[0m -}'''), + f'''\ +{ob} + {t.definition}"\\\\"{t.reset}: {t.string}""{t.reset} +{cb}'''), (r'{"\\\\": ""}', - b'''\ -{ - \x1b[94m"\\\\\\\\"\x1b[0m: \x1b[1;32m""\x1b[0m -}'''), + f'''\ +{ob} + {t.definition}"\\\\\\\\"{t.reset}: {t.string}""{t.reset} +{cb}'''), ('''\ { "foo": "bar", @@ -281,30 +288,32 @@ def test_colors(self): "qux": [true, false, null], "xyz": [NaN, -Infinity, Infinity] }''', - b'''\ -{ - \x1b[94m"foo"\x1b[0m: \x1b[1;32m"bar"\x1b[0m, - \x1b[94m"baz"\x1b[0m: 1234, - \x1b[94m"qux"\x1b[0m: [ - \x1b[1;36mtrue\x1b[0m, - \x1b[1;36mfalse\x1b[0m, - \x1b[1;36mnull\x1b[0m + f'''\ +{ob} + {t.definition}"foo"{t.reset}: {t.string}"bar"{t.reset}, + {t.definition}"baz"{t.reset}: {t.number}1234{t.reset}, + {t.definition}"qux"{t.reset}: [ + {t.keyword}true{t.reset}, + {t.keyword}false{t.reset}, + {t.keyword}null{t.reset} ], - \x1b[94m"xyz"\x1b[0m: [ - NaN, - -Infinity, - Infinity + {t.definition}"xyz"{t.reset}: [ + {t.number}NaN{t.reset}, + {t.number}-Infinity{t.reset}, + {t.number}Infinity{t.reset} ] -}'''), +{cb}'''), ) for input_, expected in cases: with self.subTest(input=input_): with open(infile, "w", encoding="utf-8") as fp: fp.write(input_) - _, stdout, _ = assert_python_ok('-m', self.module, infile, - PYTHON_COLORS='1') - stdout = stdout.replace(b'\r\n', b'\n') # normalize line endings + _, stdout_b, _ = assert_python_ok( + '-m', self.module, infile, FORCE_COLOR='1', __isolated='1' + ) + stdout = stdout_b.decode() + stdout = stdout.replace('\r\n', '\n') # normalize line endings stdout = stdout.strip() self.assertEqual(stdout, expected) From 5c45ddbb40b7afc906bb08cb960a9d3f39edd6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 5 May 2025 22:51:50 +0200 Subject: [PATCH 17/19] Make no_colors() into classmethods --- Lib/_colorize.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 8f32fda42f051a..b7f3426d2e4e8f 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -119,11 +119,12 @@ def copy_with(self, **kwargs: str) -> Self: color_state.update(kwargs) return type(self)(**color_state) - def no_colors(self) -> Self: + @classmethod + def no_colors(cls) -> Self: color_state: dict[str, str] = {} - for color_name in self.__dataclass_fields__: + for color_name in cls.__dataclass_fields__: color_state[color_name] = "" - return type(self)(**color_state) + return cls(**color_state) def __getitem__(self, key: str) -> str: return self._name_to_value(key) @@ -219,18 +220,19 @@ def copy_with( unittest=unittest or self.unittest, ) - def no_colors(self) -> Self: + @classmethod + def no_colors(cls) -> Self: """Return a new Theme where colors in all sections are empty strings. This allows writing user code as if colors are always used. The color fields will be ANSI color code strings when colorization is desired and possible, and empty strings otherwise. """ - return type(self)( - argparse=self.argparse.no_colors(), - syntax=self.syntax.no_colors(), - traceback=self.traceback.no_colors(), - unittest=self.unittest.no_colors(), + return cls( + argparse=Argparse.no_colors(), + syntax=Syntax.no_colors(), + traceback=Traceback.no_colors(), + unittest=Unittest.no_colors(), ) From 68a5c44951790e6d4836326771ab7f4f64127372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 5 May 2025 23:06:38 +0200 Subject: [PATCH 18/19] Address review --- Lib/_colorize.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index b7f3426d2e4e8f..4a310a402358b6 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -95,7 +95,23 @@ class ANSIColors: # Note that thanks to the dataclasses providing default values for all fields, # creating a new theme or theme section from scratch is possible without # specifying all keys. - +# +# For example, here's a theme that makes punctuation and operators less prominent: +# +# try: +# from _colorize import set_theme, default_theme, Syntax, ANSIColors +# except ImportError: +# pass +# else: +# theme_with_dim_operators = default_theme.copy_with( +# syntax=Syntax(op=ANSIColors.INTENSE_BLACK), +# ) +# set_theme(theme_with_dim_operators) +# del set_theme, default_theme, Syntax, ANSIColors, theme_with_dim_operators +# +# Guarding the import ensures that your .pythonstartup file will still work in +# 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]): """A mixin/base class for theme sections. @@ -103,6 +119,9 @@ class ThemeSection(Mapping[str, str]): 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 + # mixin will work in context of those fields existing __dataclass_fields__: ClassVar[dict[str, Field[str]]] _name_to_value: Callable[[str], str] @@ -318,6 +337,9 @@ def get_theme( def set_theme(t: Theme) -> None: global _theme + if not isinstance(t, Theme): + raise ValueError(f"Expected Theme object, found {t}") + _theme = t From 9f51769757e0715de9c025d6b96bacb03e03c3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Mon, 5 May 2025 23:16:14 +0200 Subject: [PATCH 19/19] Group effort --- Doc/whatsnew/3.14.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 851611ad170c91..c35e4365de025b 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -1466,7 +1466,7 @@ pdb * Source code displayed in :mod:`pdb` will be syntax-highlighted. This feature can be controlled using the same methods as PyREPL, in addition to the newly added ``colorize`` argument of :class:`pdb.Pdb`. - (Contributed by Tian Gao in :gh:`133355`.) + (Contributed by Tian Gao and Łukasz Langa in :gh:`133355`.) pickle