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 <1324225+hugovk@users.noreply.github.com>
1 parent 9cc77aa commit f610bbd

File tree

20 files changed

+581
-367
lines changed

20 files changed

+581
-367
lines changed

Doc/whatsnew/3.14.rst

Lines changed: 1 addition & 1 deletion
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

Lines changed: 219 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,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

Lines changed: 3 additions & 6 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,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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy