8000 gh-133346: Make theming support in _colorize extensible by ambv · Pull Request #133347 · python/cpython · GitHub
[go: up one dir, main page]

Skip to content

gh-133346: Make theming support in _colorize extensible #133347

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add theming to traceback.py
  • Loading branch information
ambv committed May 3, 2025
commit 252dfa059f2a8784b4a802198ae8a4d83bf5b31a
24 changes: 22 additions & 2 deletions Lib/_colorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)


Expand Down
7 changes: 4 additions & 3 deletions Lib/asyncio/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions Lib/test/test_pyrepl/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.item 10000 s()}


class TestReader(ScreenEqualMixin, TestCase):
Expand Down
114 changes: 58 additions & 56 deletions Lib/test/test_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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}}
Expand All @@ -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)
Expand All @@ -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}"<string>"{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}"<string>"{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():
Expand All @@ -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():
Expand All @@ -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()
102 changes: 46 additions & 56 deletions Lib/traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -538,22 +534,21 @@ def format_frame_summary(self, frame_summary, **kwargs):
filename = frame_summary.filename
if frame_summary.filename.startswith("<stdin>-"):
filename = "<stdin>"
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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 "<string>",
ANSIColors.RESET,
ANSIColors.MAGENTA,
self.lineno,
ANSIColors.RESET,
)
else:
yield ' File "{}", line {}\n'.format(
self.filename or "<string>", self.lineno)
yield ' File {}"{}"{}, line {}{}{}\n'.format(
theme.filename,
self.filename or "<string>",
theme.reset,
theme.line_no,
self.lineno,
theme.reset,
)
elif self.filename is not None:
filename_suffix = ' ({})'.format(self.filename)

Expand Down Expand Up @@ -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),
Expand All @@ -1456,17 +1448,15 @@ def _format_syntax_error(self, stype, **kwargs):
else:
yield ' {}\n'.format(ltext)
msg = self.msg or "<no detail available>"
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.
Expand Down
Loading
0