Skip to content

Commit 362a21b

Browse files
committed
Add experimental theming support for syntax highlighting and the prompt
1 parent 7891fa7 commit 362a21b

File tree

5 files changed

+76
-39
lines changed

5 files changed

+76
-39
lines changed

Doc/whatsnew/3.14.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,23 @@ For further information on how to build Python, see
485485
(Contributed by Ken Jin in :gh:`128563`, with ideas on how to implement this
486486
in CPython by Mark Shannon, Garrett Gu, Haoran Xu, and Josh Haberman.)
487487

488+
Syntax highlighting in PyREPL
489+
-----------------------------
490+
491+
The default :term:`interactive` shell now highlights Python syntax as you
492+
type. The feature is enabled by default unless the
493+
:envvar:`PYTHON_BASIC_REPL` environment is set or any color-disabling
494+
environment variables are used. See :ref:`using-on-controlling-color` for
495+
details.
496+
497+
The default color theme for syntax highlighting strives for good contrast
498+
and uses exclusively the 4-bit VGA standard ANSI color codes for maximum
499+
compatibility. The theme can be customized using an experimental API
500+
``_colorize.set_theme()``. This can be called interactively, as well as
501+
in the :envvar:`PYTHONSTARTUP` script.
502+
503+
(Contributed by Łukasz Langa in :gh:`131507`.)
504+
488505

489506
Other language changes
490507
======================

Lib/_colorize.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,22 @@
77

88
# types
99
if False:
10-
from typing import IO
10+
from typing import IO, Literal
11+
12+
type ColorTag = (
13+
Literal["PROMPT"]
14+
| Literal["KEYWORD"]
15+
| Literal["BUILTIN"]
16+
| Literal["COMMENT"]
17+
| Literal["STRING"]
18+
| Literal["NUMBER"]
19+
| Literal["OP"]
20+
| Literal["DEFINITION"]
21+
| Literal["SOFT_KEYWORD"]
22+
| Literal["RESET"]
23+
)
24+
25+
theme: dict[ColorTag, str]
1126

1227

1328
class ANSIColors:
@@ -110,3 +125,28 @@ def can_colorize(*, file: IO[str] | IO[bytes] | None = None) -> bool:
110125
return os.isatty(file.fileno())
111126
except io.UnsupportedOperation:
112127
return hasattr(file, "isatty") and file.isatty()
128+
129+
130+
def set_theme(t: dict[ColorTag, str] | None = None) -> None:
131+
global theme
132+
133+
if t:
134+
theme = t
135+
return
136+
137+
colors = get_colors()
138+
theme = {
139+
"PROMPT": colors.BOLD_MAGENTA,
140+
"KEYWORD": colors.BOLD_BLUE,
141+
"BUILTIN": colors.CYAN,
142+
"COMMENT": colors.RED,
143+
"STRING": colors.GREEN,
144+
"NUMBER": colors.YELLOW,
145+
"OP": colors.RESET,
146+
"DEFINITION": colors.BOLD_WHITE,
147+
"SOFT_KEYWORD": colors.BOLD_BLUE,
148+
"RESET": colors.RESET,
149+
}
150+
151+
152+
set_theme()

Lib/_pyrepl/reader.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
from __future__ import annotations
2323

2424
import sys
25+
import _colorize
2526

2627
from contextlib import contextmanager
2728
from dataclasses import dataclass, field, fields
28-
from _colorize import can_colorize, ANSIColors
2929

3030
from . import commands, console, input
3131
from .utils import wlen, unbracket, disp_str, gen_colors
@@ -273,7 +273,7 @@ def __post_init__(self) -> None:
273273
self.screeninfo = [(0, [])]
274274
self.cxy = self.pos2xy()
275275
self.lxy = (self.pos, 0)
276-
self.can_colorize = can_colorize()
276+
self.can_colorize = _colorize.can_colorize()
277277

278278
self.last_refresh_cache.screeninfo = self.screeninfo
279279
self.last_refresh_cache.pos = self.pos
@@ -492,7 +492,11 @@ def get_prompt(self, lineno: int, cursor_on_line: bool) -> str:
492492
prompt = self.ps1
493493

494494
if self.can_colorize:
495-
prompt = f"{ANSIColors.BOLD_MAGENTA}{prompt}{ANSIColors.RESET}"
495+
prompt = (
496+
f"{_colorize.theme["PROMPT"]}"
497+
f"{prompt}"
498+
f"{_colorize.theme["RESET"]}"
499+
)
496500
return prompt
497501

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

Lib/_pyrepl/utils.py

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1+
from __future__ import annotations
12
import builtins
23
import functools
34
import keyword
45
import re
56
import token as T
67
import tokenize
78
import unicodedata
9+
import _colorize
810

911
from collections import deque
1012
from io import StringIO
1113
from tokenize import TokenInfo as TI
12-
from typing import Iterable, Iterator, Literal, Match, NamedTuple, Self
13-
from _colorize import ANSIColors
14+
from typing import Iterable, Iterator, Match, NamedTuple, Self
1415

1516
from .types import CharBuffer, CharWidths
1617
from .trace import trace
@@ -21,18 +22,6 @@
2122
IDENTIFIERS_AFTER = {"def", "class"}
2223
BUILTINS = {str(name) for name in dir(builtins) if not name.startswith('_')}
2324

24-
type ColorTag = (
25-
Literal["KEYWORD"]
26-
| Literal["BUILTIN"]
27-
| Literal["COMMENT"]
28-
| Literal["STRING"]
29-
| Literal["NUMBER"]
30-
| Literal["OP"]
31-
| Literal["DEFINITION"]
32-
| Literal["SOFT_KEYWORD"]
33-
| Literal["SYNC"]
34-
)
35-
3625

3726
class Span(NamedTuple):
3827
"""Span indexing that's inclusive on both ends."""
@@ -55,20 +44,7 @@ def from_token(cls, token: TI, line_len: list[int]) -> Self:
5544

5645
class ColorSpan(NamedTuple):
5746
span: Span
58-
tag: ColorTag
59-
60-
61-
TAG_TO_ANSI: dict[ColorTag, str] = {
62-
"KEYWORD": ANSIColors.BOLD_BLUE,
63-
"BUILTIN": ANSIColors.CYAN,
64-
"COMMENT": ANSIColors.RED,
65-
"STRING": ANSIColors.GREEN,
66-
"NUMBER": ANSIColors.YELLOW,
67-
"OP": ANSIColors.RESET,
68-
"DEFINITION": ANSIColors.BOLD_WHITE,
69-
"SOFT_KEYWORD": ANSIColors.BOLD_BLUE,
70-
"SYNC": ANSIColors.RESET,
71-
}
47+
tag: _colorize.ColorTag
7248

7349

7450
@functools.cache
@@ -305,11 +281,11 @@ def disp_str(
305281
post_color = ""
306282
if colors and colors[0].span.start < start_index:
307283
# looks like we're continuing a previous color (e.g. a multiline str)
308-
pre_color = TAG_TO_ANSI[colors[0].tag]
284+
pre_color = _colorize.theme[colors[0].tag]
309285

310286
for i, c in enumerate(buffer, start_index):
311287
if colors and colors[0].span.start == i: # new color starts now
312-
pre_color = TAG_TO_ANSI[colors[0].tag]
288+
pre_color = _colorize.theme[colors[0].tag]
313289

314290
if c == "\x1a": # CTRL-Z on Windows
315291
chars.append(c)
@@ -326,7 +302,7 @@ def disp_str(
326302
char_widths.append(str_width(c))
327303

328304
if colors and colors[0].span.end == i: # current color ends now
329-
post_color = TAG_TO_ANSI["SYNC"]
305+
post_color = _colorize.theme["RESET"]
330306
colors.pop(0)
331307

332308
chars[-1] = pre_color + chars[-1] + post_color
@@ -336,7 +312,7 @@ def disp_str(
336312
if colors and colors[0].span.start < i and colors[0].span.end > i:
337313
# even though the current color should be continued, reset it for now.
338314
# the next call to `disp_str()` will revive it.
339-
chars[-1] += TAG_TO_ANSI["SYNC"]
315+
chars[-1] += _colorize.theme["RESET"]
340316

341317
# trace("disp_str({buffer}) = {s}, {b}", buffer=repr(buffer), s=chars, b=char_widths)
342318
return chars, char_widths

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 _pyrepl.utils import TAG_TO_ANSI
14+
from _colorize import theme
1515

1616

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

2020

2121
class TestReader(ScreenEqualMixin, TestCase):

0 commit comments

Comments
 (0)