Skip to content

Commit f610bbd

Browse files
ambvhugovk
andauthored
gh-133346: Make theming support in _colorize extensible (GH-133347)
Co-authored-by: Hugo van Kemenade <[email protected]>
1 parent 9cc77aa commit f610bbd

20 files changed

+581
-367
lines changed

Doc/whatsnew/3.14.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -1466,7 +1466,7 @@ pdb
14661466
* Source code displayed in :mod:`pdb` will be syntax-highlighted. This feature
14671467
can be controlled using the same methods as PyREPL, in addition to the newly
14681468
added ``colorize`` argument of :class:`pdb.Pdb`.
1469-
(Contributed by Tian Gao in :gh:`133355`.)
1469+
(Contributed by Tian Gao and Łukasz Langa in :gh:`133355`.)
14701470

14711471

14721472
pickle

Lib/_colorize.py

+219-36
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,186 @@ class ANSIColors:
8675
setattr(NoColors, attr, "")
8776

8877

78+
#
79+
# Experimental theming support (see gh-133346)
80+
#
81+
82+
# - Create a theme by copying an existing `Theme` with one or more sections
83+
# replaced, using `default_theme.copy_with()`;
84+
# - create a theme section by copying an existing `ThemeSection` with one or
85+
# more colors replaced, using for example `default_theme.syntax.copy_with()`;
86+
# - create a theme from scratch by instantiating a `Theme` data class with
87+
# the required sections (which are also dataclass instances).
88+
#
89+
# Then call `_colorize.set_theme(your_theme)` to set it.
90+
#
91+
# Put your theme configuration in $PYTHONSTARTUP for the interactive shell,
92+
# or sitecustomize.py in your virtual environment or Python installation for
93+
# other uses. Your applications can call `_colorize.set_theme()` too.
94+
#
95+
# Note that thanks to the dataclasses providing default values for all fields,
96+
# creating a new theme or theme section from scratch is possible without
97+
# specifying all keys.
98+
#
99+
# For example, here's a theme that makes punctuation and operators less prominent:
100+
#
101+
# try:
102+
# from _colorize import set_theme, default_theme, Syntax, ANSIColors
103+
# except ImportError:
104+
# pass
105+
# else:
106+
# theme_with_dim_operators = default_theme.copy_with(
107+
# syntax=Syntax(op=ANSIColors.INTENSE_BLACK),
108+
# )
109+
# set_theme(theme_with_dim_operators)
110+
# del set_theme, default_theme, Syntax, ANSIColors, theme_with_dim_operators
111+
#
112+
# Guarding the import ensures that your .pythonstartup file will still work in
113+
# Python 3.13 and older. Deleting the variables ensures they don't remain in your
114+
# interactive shell's global scope.
115+
116+
class ThemeSection(Mapping[str, str]):
117+
"""A mixin/base class for theme sections.
118+
119+
It enables dictionary access to a section, as well as implements convenience
120+
methods.
121+
"""
122+
123+
# The two types below are just that: types to inform the type checker that the
124+
# mixin will work in context of those fields existing
125+
__dataclass_fields__: ClassVar[dict[str, Field[str]]]
126+
_name_to_value: Callable[[str], str]
127+
128+
def __post_init__(self) -> None:
129+
name_to_value = {}
130+
for color_name in self.__dataclass_fields__:
131+
name_to_value[color_name] = getattr(self, color_name)
132+
super().__setattr__('_name_to_value', name_to_value.__getitem__)
133+
134+
def copy_with(self, **kwargs: str) -> Self:
135+
color_state: dict[str, str] = {}
136+
for color_name in self.__dataclass_fields__:
137+
color_state[color_name] = getattr(self, color_name)
138+
color_state.update(kwargs)
139+
return type(self)(**color_state)
140+
141+
@classmethod
142+
def no_colors(cls) -> Self:
143+
color_state: dict[str, str] = {}
144+
for color_name in cls.__dataclass_fields__:
145+
color_state[color_name] = ""
146+
return cls(**color_state)
147+
148+
def __getitem__(self, key: str) -> str:
149+
return self._name_to_value(key)
150+
151+
def __len__(self) -> int:
152+
return len(self.__dataclass_fields__)
153+
154+
def __iter__(self) -> Iterator[str]:
155+
return iter(self.__dataclass_fields__)
156+
157+
158+
@dataclass(frozen=True)
159+
class Argparse(ThemeSection):
160+
usage: str = ANSIColors.BOLD_BLUE
161+
prog: str = ANSIColors.BOLD_MAGENTA
162+
prog_extra: str = ANSIColors.MAGENTA
163+
heading: str = ANSIColors.BOLD_BLUE
164+
summary_long_option: str = ANSIColors.CYAN
165+
summary_short_option: str = ANSIColors.GREEN
166+
summary_label: str = ANSIColors.YELLOW
167+
summary_action: str = ANSIColors.GREEN
168+
long_option: str = ANSIColors.BOLD_CYAN
169+
short_option: str = ANSIColors.BOLD_GREEN
170+
label: str = ANSIColors.BOLD_YELLOW
171+
action: str = ANSIColors.BOLD_GREEN
172+
reset: str = ANSIColors.RESET
173+
174+
175+
@dataclass(frozen=True)
176+
class Syntax(ThemeSection):
177+
prompt: str = ANSIColors.BOLD_MAGENTA
178+
keyword: str = ANSIColors.BOLD_BLUE
179+
builtin: str = ANSIColors.CYAN
180+
comment: str = ANSIColors.RED
181+
string: str = ANSIColors.GREEN
182+
number: str = ANSIColors.YELLOW
183+
op: str = ANSIColors.RESET
184+
definition: str = ANSIColors.BOLD
185+
soft_keyword: str = ANSIColors.BOLD_BLUE
186+
reset: str = ANSIColors.RESET
187+
188+
189+
@dataclass(frozen=True)
190+
class Traceback(ThemeSection):
191+
type: str = ANSIColors.BOLD_MAGENTA
192+
message: str = ANSIColors.MAGENTA
193+
filename: str = ANSIColors.MAGENTA
194+
line_no: str = ANSIColors.MAGENTA
195+
frame: str = ANSIColors.MAGENTA
196+
error_highlight: str = ANSIColors.BOLD_RED
197+
error_range: str = ANSIColors.RED
198+
reset: str = ANSIColors.RESET
199+
200+
201+
@dataclass(frozen=True)
202+
class Unittest(ThemeSection):
203+
passed: str = ANSIColors.GREEN
204+
warn: str = ANSIColors.YELLOW
205+
fail: str = ANSIColors.RED
206+
fail_info: str = ANSIColors.BOLD_RED
207+
reset: str = ANSIColors.RESET
208+
209+
210+
@dataclass(frozen=True)
211+
class Theme:
212+
"""A suite of themes for all sections of Python.
213+
214+
When adding a new one, remember to also modify `copy_with` and `no_colors`
215+
below.
216+
"""
217+
argparse: Argparse = field(default_factory=Argparse)
218+
syntax: Syntax = field(default_factory=Syntax)
219+
traceback: Traceback = field(default_factory=Traceback)
220+
unittest: Unittest = field(default_factory=Unittest)
221+
222+
def copy_with(
223+
self,
224+
*,
225+
argparse: Argparse | None = None,
226+
syntax: Syntax | None = None,
227+
traceback: Traceback | None = None,
228+
unittest: Unittest | None = None,
229+
) -> Self:
230+
"""Return a new Theme based on this instance with some sections replaced.
231+
232+
Themes are immutable to protect against accidental modifications that
233+
could lead to invalid terminal states.
234+
"""
235+
return type(self)(
236+
argparse=argparse or self.argparse,
237+
syntax=syntax or self.syntax,
238+
traceback=traceback or self.traceback,
239+
unittest=unittest or self.unittest,
240+
)
241+
242+
@classmethod
243+
def no_colors(cls) -> Self:
244+
"""Return a new Theme where colors in all sections are empty strings.
245+
246+
This allows writing user code as if colors are always used. The color
247+
fields will be ANSI color code strings when colorization is desired
248+
and possible, and empty strings otherwise.
249+
"""
250+
return cls(
251+
argparse=Argparse.no_colors(),
252+
syntax=Syntax.no_colors(),
253+
traceback=Traceback.no_colors(),
254+
unittest=Unittest.no_colors(),
255+
)
256+
257+
89258
def get_colors(
90259
colorize: bool = False, *, file: IO[str] | IO[bytes] | None = None
91260
) -> ANSIColors:
@@ -138,26 +307,40 @@ def can_colorize(*, file: IO[str] | IO[bytes] | None = None) -> bool:
138307
return hasattr(file, "isatty") and file.isatty()
139308

140309

141-
def set_theme(t: dict[ColorTag, str] | None = None) -> None:
142-
global theme
310+
default_theme = Theme()
311+
theme_no_color = default_theme.no_colors()
312+
313+
314+
def get_theme(
315+
*,
316+
tty_file: IO[str] | IO[bytes] | None = None,
317+
force_color: bool = False,
318+
force_no_color: bool = False,
319+
) -> Theme:
320+
"""Returns the currently set theme, potentially in a zero-color variant.
321+
322+
In cases where colorizing is not possible (see `can_colorize`), the returned
323+
theme contains all empty strings in all color definitions.
324+
See `Theme.no_colors()` for more information.
325+
326+
It is recommended not to cache the result of this function for extended
327+
periods of time because the user might influence theme selection by
328+
the interactive shell, a debugger, or application-specific code. The
329+
environment (including environment variable state and console configuration
330+
on Windows) can also change in the course of the application life cycle.
331+
"""
332+
if force_color or (not force_no_color and can_colorize(file=tty_file)):
333+
return _theme
334+
return theme_no_color
335+
336+
337+
def set_theme(t: Theme) -> None:
338+
global _theme
143339

144-
if t:
145-
theme = t
146-
return
340+
if not isinstance(t, Theme):
341+
raise ValueError(f"Expected Theme object, found {t}")
147342

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-
}
343+
_theme = t
161344

162345

163-
set_theme()
346+
set_theme(default_theme)

Lib/_pyrepl/reader.py

+3-6
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,11 +491,8 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str:
491491
prompt = self.ps1
492492

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

501498
def push_input_trans(self, itrans: input.KeymapTranslator) -> None:

0 commit comments

Comments
 (0)