Skip to content

Commit 3344008

Browse files
committed
gh-133346: Make theming support in _colorize extensible
1 parent 77c391a commit 3344008

File tree

4 files changed

+109
-56
lines changed

4 files changed

+109
-56
lines changed

Lib/_colorize.py

Lines changed: 82 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,17 @@
1-
from __future__ import annotations
21
import io
32
import os
43
import sys
54

5+
from collections.abc import Callable, Iterator, Mapping
6+
from dataclasses import dataclass, field, Field
7+
68
COLORIZE = True
79

10+
811
# types
912
if False:
10-
from typing import IO, Literal
11-
12-
type ColorTag = Literal[
13-
"PROMPT",
14-
"KEYWORD",
15-
"BUILTIN",
16-
"COMMENT",
17-
"STRING",
18-
"NUMBER",
19-
"OP",
20-
"DEFINITION",
21-
"SOFT_KEYWORD",
22-
"RESET",
23-
]
24-
25-
theme: dict[ColorTag, str]
13+
from typing import IO, Self, ClassVar
14+
_theme: Theme
2615

2716

2817
class ANSIColors:
@@ -86,6 +75,68 @@ class ANSIColors:
8675
setattr(NoColors, attr, "")
8776

8877

78+
class ThemeSection(Mapping[str, str]):
79+
__dataclass_fields__: ClassVar[dict[str, Field[str]]]
80+
_name_to_value: Callable[[str], str]
81+
82+
def __post_init__(self) -> None:
83+
name_to_value = {}
84+
for color_name, color_field in self.__dataclass_fields__.items():
85+
name_to_value[color_name] = getattr(self, color_name)
86+
super().__setattr__('_name_to_value', name_to_value.__getitem__)
87+
88+
def copy_with(self, **kwargs: str) -> Self:
89+
color_state: dict[str, str] = {}
90+
for color_name, color_field in self.__dataclass_fields__.items():
91+
color_state[color_name] = getattr(self, color_name)
92+
color_state.update(kwargs)
93+
return type(self)(**color_state)
94+
95+
def no_colors(self) -> Self:
96+
color_state: dict[str, str] = {}
97+
for color_name, color_field in self.__dataclass_fields__.items():
98+
color_state[color_name] = ""
99+
return type(self)(**color_state)
100+
101+
def __getitem__(self, key: str) -> str:
102+
return self._name_to_value(key)
103+
104+
def __len__(self) -> int:
105+
return len(self.__dataclass_fields__)
106+
107+
def __iter__(self) -> Iterator[str]:
108+
return iter(self.__dataclass_fields__)
109+
110+
111+
@dataclass(frozen=True)
112+
class REPL(ThemeSection):
113+
prompt: str = ANSIColors.BOLD_MAGENTA
114+
keyword: str = ANSIColors.BOLD_BLUE
115+
builtin: str = ANSIColors.CYAN
116+
comment: str = ANSIColors.RED
117+
string: str = ANSIColors.GREEN
118+
number: str = ANSIColors.YELLOW
119+
op: str = ANSIColors.RESET
120+
definition: str = ANSIColors.BOLD
121+
soft_keyword: str = ANSIColors.BOLD_BLUE
122+
reset: str = ANSIColors.RESET
123+
124+
125+
@dataclass(frozen=True)
126+
class Theme:
127+
repl: REPL = field(default_factory=REPL)
128+
129+
def copy_with(self, *, repl: REPL | None) -> Self:
130+
return type(self)(
131+
repl=repl or self.repl,
132+
)
133+
134+
def no_colors(self) -> Self:
135+
return type(self)(
136+
repl=self.repl.no_colors(),
137+
)
138+
139+
89140
def get_colors(
90141
colorize: bool = False, *, file: IO[str] | IO[bytes] | None = None
91142
) -> ANSIColors:
@@ -138,26 +189,21 @@ def can_colorize(*, file: IO[str] | IO[bytes] | None = None) -> bool:
138189
return hasattr(file, "isatty") and file.isatty()
139190

140191

141-
def set_theme(t: dict[ColorTag, str] | None = None) -> None:
142-
global theme
192+
default_theme = Theme()
193+
theme_no_color = default_theme.no_colors()
194+
195+
196+
def get_theme(*, tty_file: IO[str] | IO[bytes] | None = None) -> Theme:
197+
if can_colorize(file=tty_file):
198+
return _theme
199+
return theme_no_color
200+
143201

144-
if t:
145-
theme = t
146-
return
202+
def set_theme(t: Theme) -> None:
203+
global _theme
147204

148-
colors = get_colors()
149-
theme = {
150-
"PROMPT": colors.BOLD_MAGENTA,
151-
"KEYWORD": colors.BOLD_BLUE,
152-
"BUILTIN": colors.CYAN,
153-
"COMMENT": colors.RED,
154-
"STRING": colors.GREEN,
155-
"NUMBER": colors.YELLOW,
156-
"OP": colors.RESET,
157-
"DEFINITION": colors.BOLD,
158-
"SOFT_KEYWORD": colors.BOLD_BLUE,
159-
"RESET": colors.RESET,
160-
}
205+
_theme = t
206+
return
161207

162208

163-
set_theme()
209+
set_theme(default_theme)

Lib/_pyrepl/reader.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from dataclasses import dataclass, field, fields
2929

3030
from . import commands, console, input
31-
from .utils import wlen, unbracket, disp_str, gen_colors
31+
from .utils import wlen, unbracket, disp_str, gen_colors, THEME
3232
from .trace import trace
3333

3434

@@ -491,10 +491,11 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str:
491491
prompt = self.ps1
492492

493493
if self.can_colorize:
494+
t = THEME()
494495
prompt = (
495-
f"{_colorize.theme["PROMPT"]}"
496+
f"{t.prompt}"
496497
f"{prompt}"
497-
f"{_colorize.theme["RESET"]}"
498+
f"{t.reset}"
498499
)
499500
return prompt
500501

Lib/_pyrepl/utils.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
BUILTINS = {str(name) for name in dir(builtins) if not name.startswith('_')}
2424

2525

26+
def THEME():
27+
# Not cached: the user can modify the theme inside the interactive session.
28+
return _colorize.get_theme().repl
29+
30+
2631
class Span(NamedTuple):
2732
"""Span indexing that's inclusive on both ends."""
2833

@@ -44,7 +49,7 @@ def from_token(cls, token: TI, line_len: list[int]) -> Self:
4449

4550
class ColorSpan(NamedTuple):
4651
span: Span
47-
tag: _colorize.ColorTag
52+
tag: str
4853

4954

5055
@functools.cache
@@ -135,7 +140,7 @@ def recover_unterminated_string(
135140

136141
span = Span(start, end)
137142
trace("yielding span {a} -> {b}", a=span.start, b=span.end)
138-
yield ColorSpan(span, "STRING")
143+
yield ColorSpan(span, "string")
139144
else:
140145
trace(
141146
"unhandled token error({buffer}) = {te}",
@@ -164,28 +169,28 @@ def gen_colors_from_token_stream(
164169
| T.TSTRING_START | T.TSTRING_MIDDLE | T.TSTRING_END
165170
):
166171
span = Span.from_token(token, line_lengths)
167-
yield ColorSpan(span, "STRING")
172+
yield ColorSpan(span, "string")
168173
case T.COMMENT:
169174
span = Span.from_token(token, line_lengths)
170-
yield ColorSpan(span, "COMMENT")
175+
yield ColorSpan(span, "comment")
171176
case T.NUMBER:
172177
span = Span.from_token(token, line_lengths)
173-
yield ColorSpan(span, "NUMBER")
178+
yield ColorSpan(span, "number")
174179
case T.OP:
175180
if token.string in "([{":
176181
bracket_level += 1
177182
elif token.string in ")]}":
178183
bracket_level -= 1
179184
span = Span.from_token(token, line_lengths)
180-
yield ColorSpan(span, "OP")
185+
yield ColorSpan(span, "op")
181186
case T.NAME:
182187
if is_def_name:
183188
is_def_name = False
184189
span = Span.from_token(token, line_lengths)
185-
yield ColorSpan(span, "DEFINITION")
190+
yield ColorSpan(span, "definition")
186191
elif keyword.iskeyword(token.string):
187192
span = Span.from_token(token, line_lengths)
188-
yield ColorSpan(span, "KEYWORD")
193+
yield ColorSpan(span, "keyword")
189194
if token.string in IDENTIFIERS_AFTER:
190195
is_def_name = True
191196
elif (
@@ -194,10 +199,10 @@ def gen_colors_from_token_stream(
194199
and is_soft_keyword_used(prev_token, token, next_token)
195200
):
196201
span = Span.from_token(token, line_lengths)
197-
yield ColorSpan(span, "SOFT_KEYWORD")
202+
yield ColorSpan(span, "soft_keyword")
198203
elif token.string in BUILTINS:
199204
span = Span.from_token(token, line_lengths)
200-
yield ColorSpan(span, "BUILTIN")
205+
yield ColorSpan(span, "builtin")
201206

202207

203208
keyword_first_sets_match = {"False", "None", "True", "await", "lambda", "not"}
@@ -290,15 +295,16 @@ def disp_str(
290295
# move past irrelevant spans
291296
colors.pop(0)
292297

298+
theme = THEME()
293299
pre_color = ""
294300
post_color = ""
295301
if colors and colors[0].span.start < start_index:
296302
# looks like we're continuing a previous color (e.g. a multiline str)
297-
pre_color = _colorize.theme[colors[0].tag]
303+
pre_color = theme[colors[0].tag]
298304

299305
for i, c in enumerate(buffer, start_index):
300306
if colors and colors[0].span.start == i: # new color starts now
301-
pre_color = _colorize.theme[colors[0].tag]
307+
pre_color = theme[colors[0].tag]
302308

303309
if c == "\x1a": # CTRL-Z on Windows
304310
chars.append(c)
@@ -315,7 +321,7 @@ def disp_str(
315321
char_widths.append(str_width(c))
316322

317323
if colors and colors[0].span.end == i: # current color ends now
318-
post_color = _colorize.theme["RESET"]
324+
post_color = theme.reset
319325
colors.pop(0)
320326

321327
chars[-1] = pre_color + chars[-1] + post_color
@@ -325,7 +331,7 @@ def disp_str(
325331
if colors and colors[0].span.start < i and colors[0].span.end > i:
326332
# even though the current color should be continued, reset it for now.
327333
# the next call to `disp_str()` will revive it.
328-
chars[-1] += _colorize.theme["RESET"]
334+
chars[-1] += theme.reset
329335

330336
return chars, char_widths
331337

Lib/test/test_pyrepl/test_reader.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
from .support import reader_no_colors as prepare_reader
1212
from _pyrepl.console import Event
1313
from _pyrepl.reader import Reader
14-
from _colorize import theme
14+
from _colorize import get_theme
1515

1616

17-
overrides = {"RESET": "z", "SOFT_KEYWORD": "K"}
18-
colors = {overrides.get(k, k[0].lower()): v for k, v in theme.items()}
17+
overrides = {"reset": "z", "soft_keyword": "K"}
18+
colors = {overrides.get(k, k[0].lower()): v for k, v in get_theme().repl.items()}
1919

2020

2121
class TestReader(ScreenEqualMixin, TestCase):

0 commit comments

Comments
 (0)