From 799601ec4eeb421e6e33472bd7251d74c7ffb5c3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:02:07 +0300 Subject: [PATCH 1/6] Add colour to pickletools CLI output --- Doc/library/pickletools.rst | 3 ++ Doc/whatsnew/3.15.rst | 9 ++++ Lib/_colorize.py | 17 ++++++++ Lib/pickletools.py | 43 ++++++++++++------- Lib/test/test_pickletools.py | 2 + ...-04-26-23-01-50.gh-issue-149026.Akk4Bc.rst | 1 + 6 files changed, 60 insertions(+), 15 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-04-26-23-01-50.gh-issue-149026.Akk4Bc.rst diff --git a/Doc/library/pickletools.rst b/Doc/library/pickletools.rst index 7a771ea3ab93d4..e753ad3b08b81a 100644 --- a/Doc/library/pickletools.rst +++ b/Doc/library/pickletools.rst @@ -79,6 +79,9 @@ Command-line options A pickle file to read, or ``-`` to indicate reading from standard input. +.. versionadded:: next + Output is in color by default and can be + :ref:`controlled using environment variables `. Programmatic interface diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 405d388af487e8..8ba8f25af8ebf4 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1010,6 +1010,15 @@ pickle (Contributed by Zackery Spytz and Serhiy Storchaka in :gh:`77188`.) +pickletools +----------- + +* The output of the :mod:`pickletools` command-line interface is colored by + default. This can be controlled with + :ref:`environment variables `. + (Contributed by Hugo van Kemenade in :gh:`149026`.) + + pprint ------ diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 379ca2529b6585..bf7b98002710f5 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -359,6 +359,19 @@ class LiveProfiler(ThemeSection): ) +@dataclass(frozen=True, kw_only=True) +class Pickletools(ThemeSection): + annotation: str = ANSIColors.GREY + arg_number: str = ANSIColors.YELLOW + arg_string: str = ANSIColors.GREEN + mark: str = ANSIColors.GREY + opcode_code: str = ANSIColors.CYAN + opcode_name: str = ANSIColors.BOLD_BLUE + position: str = ANSIColors.GREY + proto: str = ANSIColors.YELLOW + reset: str = ANSIColors.RESET + + @dataclass(frozen=True, kw_only=True) class Syntax(ThemeSection): prompt: str = ANSIColors.BOLD_MAGENTA @@ -429,6 +442,7 @@ class Theme: fancycompleter: FancyCompleter = field(default_factory=FancyCompleter) http_server: HttpServer = field(default_factory=HttpServer) live_profiler: LiveProfiler = field(default_factory=LiveProfiler) + pickletools: Pickletools = field(default_factory=Pickletools) syntax: Syntax = field(default_factory=Syntax) timeit: Timeit = field(default_factory=Timeit) tokenize: Tokenize = field(default_factory=Tokenize) @@ -444,6 +458,7 @@ def copy_with( fancycompleter: FancyCompleter | None = None, http_server: HttpServer | None = None, live_profiler: LiveProfiler | None = None, + pickletools: Pickletools | None = None, syntax: Syntax | None = None, timeit: Timeit | None = None, tokenize: Tokenize | None = None, @@ -462,6 +477,7 @@ def copy_with( fancycompleter=fancycompleter or self.fancycompleter, http_server=http_server or self.http_server, live_profiler=live_profiler or self.live_profiler, + pickletools=pickletools or self.pickletools, syntax=syntax or self.syntax, timeit=timeit or self.timeit, tokenize=tokenize or self.tokenize, @@ -484,6 +500,7 @@ def no_colors(cls) -> Self: fancycompleter=FancyCompleter.no_colors(), http_server=HttpServer.no_colors(), live_profiler=LiveProfiler.no_colors(), + pickletools=Pickletools.no_colors(), syntax=Syntax.no_colors(), timeit=Timeit.no_colors(), tokenize=Tokenize.no_colors(), diff --git a/Lib/pickletools.py b/Lib/pickletools.py index 29baf3be7ebb6e..81c83916b0816d 100644 --- a/Lib/pickletools.py +++ b/Lib/pickletools.py @@ -16,6 +16,8 @@ import re import sys +lazy from _colorize import decolor, get_theme + __all__ = ['dis', 'genops', 'optimize'] bytes_types = pickle.bytes_types @@ -2443,13 +2445,16 @@ def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): indentchunk = ' ' * indentlevel errormsg = None annocol = annotate # column hint for annotations + t = get_theme(tty_file=out if out is not None else sys.stdout).pickletools for opcode, arg, pos in genops(pickle): if pos is not None: - print("%5d:" % pos, end=' ', file=out) + print(f"{t.position}{pos:5d}:{t.reset}", end=' ', file=out) - line = "%-4s %s%s" % (repr(opcode.code)[1:-1], - indentchunk * len(markstack), - opcode.name) + line = ( + f"{t.opcode_code}{repr(opcode.code)[1:-1]:<4}{t.reset} " + f"{indentchunk * len(markstack)}" + f"{t.opcode_name}{opcode.name}{t.reset}" + ) maxproto = max(maxproto, opcode.proto) before = opcode.stack_before # don't mutate @@ -2510,18 +2515,26 @@ def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): line += ' ' * (10 - len(opcode.name)) if arg is not None: if opcode.name in ("STRING", "BINSTRING", "SHORT_BINSTRING"): - line += ' ' + ascii(arg) + arg_text = ascii(arg) else: - line += ' ' + repr(arg) + arg_text = repr(arg) + arg_color = ( + t.arg_number + if isinstance(arg, (int, float)) + else t.arg_string + ) + line += f" {arg_color}{arg_text}{t.reset}" if markmsg: - line += ' ' + markmsg + line += f" {t.mark}{markmsg}{t.reset}" if annotate: - line += ' ' * (annocol - len(line)) + visible_len = len(decolor(line)) + line += ' ' * (annocol - visible_len) # make a mild effort to align annotations - annocol = len(line) + annocol = max(visible_len, annocol) if annocol > 50: annocol = annotate - line += ' ' + opcode.doc.split('\n', 1)[0] + doc = opcode.doc.split('\n', 1)[0] + line += f" {t.annotation}{doc}{t.reset}" print(line, file=out) if errormsg: @@ -2541,7 +2554,10 @@ def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): stack.extend(after) - print("highest protocol among opcodes =", maxproto, file=out) + print( + f"highest protocol among opcodes = {t.proto}{maxproto}{t.reset}", + file=out, + ) if stack: raise ValueError("stack not empty after STOP: %r" % stack) @@ -2841,10 +2857,7 @@ def __init__(self, value): def _main(args=None): import argparse - parser = argparse.ArgumentParser( - description='disassemble one or more pickle files', - color=True, - ) + parser = argparse.ArgumentParser(description='disassemble one or more pickle files') parser.add_argument( 'pickle_file', nargs='+', help='the pickle file') diff --git a/Lib/test/test_pickletools.py b/Lib/test/test_pickletools.py index 57285ddf6ebef5..caf2d7ba6bfd8f 100644 --- a/Lib/test/test_pickletools.py +++ b/Lib/test/test_pickletools.py @@ -160,6 +160,7 @@ def test_unknown_opcode_without_pos(self): next(it) +@support.force_not_colorized_test_class class DisTests(unittest.TestCase): maxDiff = None @@ -518,6 +519,7 @@ def test__all__(self): support.check__all__(self, pickletools, not_exported=not_exported) +@support.force_not_colorized_test_class class CommandLineTest(unittest.TestCase): def setUp(self): self.filename = tempfile.mktemp() diff --git a/Misc/NEWS.d/next/Library/2026-04-26-23-01-50.gh-issue-149026.Akk4Bc.rst b/Misc/NEWS.d/next/Library/2026-04-26-23-01-50.gh-issue-149026.Akk4Bc.rst new file mode 100644 index 00000000000000..d12a92e9f530da --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-04-26-23-01-50.gh-issue-149026.Akk4Bc.rst @@ -0,0 +1 @@ +Add colour to :mod:`pickletools` CLI output. Patch by Hugo van Kemenade. From 35d1088de510aaf36cde533f7c0616cbe1c141f2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:22:25 +0300 Subject: [PATCH 2/6] Categorise opcodes --- Lib/_colorize.py | 7 ++++++- Lib/pickletools.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index bf7b98002710f5..ac844c2728c6f6 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -365,8 +365,13 @@ class Pickletools(ThemeSection): arg_number: str = ANSIColors.YELLOW arg_string: str = ANSIColors.GREEN mark: str = ANSIColors.GREY + op_call: str = ANSIColors.GREEN + op_container: str = ANSIColors.INTENSE_BLUE + op_literal: str = ANSIColors.CYAN + op_memo: str = ANSIColors.MAGENTA + op_meta: str = ANSIColors.GREY + op_stack: str = ANSIColors.BOLD_RED opcode_code: str = ANSIColors.CYAN - opcode_name: str = ANSIColors.BOLD_BLUE position: str = ANSIColors.GREY proto: str = ANSIColors.YELLOW reset: str = ANSIColors.RESET diff --git a/Lib/pickletools.py b/Lib/pickletools.py index 81c83916b0816d..5df06a4477de26 100644 --- a/Lib/pickletools.py +++ b/Lib/pickletools.py @@ -2394,6 +2394,38 @@ def optimize(p): ############################################################################## # A symbolic pickle disassembler. +# Group opcode names into categories for colourised CLI output. +_opcode_categories = frozendict( + op_call=frozenset({ + "BUILD", "EXT1", "EXT2", "EXT4", "GLOBAL", "INST", "NEWOBJ", + "NEWOBJ_EX", "OBJ", "REDUCE", "STACK_GLOBAL", + }), + op_container=frozenset({ + "ADDITEMS", "APPEND", "APPENDS", "DICT", "EMPTY_DICT", "EMPTY_LIST", + "EMPTY_SET", "EMPTY_TUPLE", "FROZENSET", "LIST", "SETITEM", + "SETITEMS", "TUPLE", "TUPLE1", "TUPLE2", "TUPLE3", + }), + op_literal=frozenset({ + "BINBYTES", "BINBYTES8", "BINFLOAT", "BININT", "BININT1", "BININT2", + "BINSTRING", "BINUNICODE", "BINUNICODE8", "BYTEARRAY8", "FLOAT", + "INT", "LONG", "LONG1", "LONG4", "NEWFALSE", "NEWTRUE", "NEXT_BUFFER", + "NONE", "READONLY_BUFFER", "SHORT_BINBYTES", "SHORT_BINSTRING", + "SHORT_BINUNICODE", "STRING", "UNICODE", + }), + op_memo=frozenset({ + "BINGET", "BINPUT", "GET", "LONG_BINGET", "LONG_BINPUT", "MEMOIZE", + "PUT", + }), + op_meta=frozenset({"BINPERSID", "FRAME", "PERSID", "PROTO"}), + op_stack=frozenset({"DUP", "MARK", "POP", "POP_MARK", "STOP"}), +) +_opcode_color_attr = frozendict({ + name: attr + for attr, names in _opcode_categories.items() + for name in names +}) + + def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): """Produce a symbolic disassembly of a pickle. @@ -2450,10 +2482,13 @@ def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): if pos is not None: print(f"{t.position}{pos:5d}:{t.reset}", end=' ', file=out) + opcode_color = getattr( + t, _opcode_color_attr.get(opcode.name, "op_meta") + ) line = ( f"{t.opcode_code}{repr(opcode.code)[1:-1]:<4}{t.reset} " f"{indentchunk * len(markstack)}" - f"{t.opcode_name}{opcode.name}{t.reset}" + f"{opcode_color}{opcode.name}{t.reset}" ) maxproto = max(maxproto, opcode.proto) From 07671f867f432a0f5dd39520bc08bcb7ef3e19a3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:03:24 +0300 Subject: [PATCH 3/6] MARK in grey --- Lib/_colorize.py | 1 - Lib/pickletools.py | 19 ++++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index ac844c2728c6f6..62806b1d8d7bcf 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -367,7 +367,6 @@ class Pickletools(ThemeSection): mark: str = ANSIColors.GREY op_call: str = ANSIColors.GREEN op_container: str = ANSIColors.INTENSE_BLUE - op_literal: str = ANSIColors.CYAN op_memo: str = ANSIColors.MAGENTA op_meta: str = ANSIColors.GREY op_stack: str = ANSIColors.BOLD_RED diff --git a/Lib/pickletools.py b/Lib/pickletools.py index 5df06a4477de26..3ef93e7dfa5123 100644 --- a/Lib/pickletools.py +++ b/Lib/pickletools.py @@ -2405,19 +2405,12 @@ def optimize(p): "EMPTY_SET", "EMPTY_TUPLE", "FROZENSET", "LIST", "SETITEM", "SETITEMS", "TUPLE", "TUPLE1", "TUPLE2", "TUPLE3", }), - op_literal=frozenset({ - "BINBYTES", "BINBYTES8", "BINFLOAT", "BININT", "BININT1", "BININT2", - "BINSTRING", "BINUNICODE", "BINUNICODE8", "BYTEARRAY8", "FLOAT", - "INT", "LONG", "LONG1", "LONG4", "NEWFALSE", "NEWTRUE", "NEXT_BUFFER", - "NONE", "READONLY_BUFFER", "SHORT_BINBYTES", "SHORT_BINSTRING", - "SHORT_BINUNICODE", "STRING", "UNICODE", - }), op_memo=frozenset({ "BINGET", "BINPUT", "GET", "LONG_BINGET", "LONG_BINPUT", "MEMOIZE", "PUT", }), - op_meta=frozenset({"BINPERSID", "FRAME", "PERSID", "PROTO"}), - op_stack=frozenset({"DUP", "MARK", "POP", "POP_MARK", "STOP"}), + op_meta=frozenset({"BINPERSID", "FRAME", "MARK", "PERSID", "PROTO"}), + op_stack=frozenset({"DUP", "POP", "POP_MARK", "STOP"}), ) _opcode_color_attr = frozendict({ name: attr @@ -2482,13 +2475,13 @@ def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): if pos is not None: print(f"{t.position}{pos:5d}:{t.reset}", end=' ', file=out) - opcode_color = getattr( - t, _opcode_color_attr.get(opcode.name, "op_meta") - ) + attr = _opcode_color_attr.get(opcode.name) + opcode_color = getattr(t, attr) if attr else "" + opcode_reset = t.reset if attr else "" line = ( f"{t.opcode_code}{repr(opcode.code)[1:-1]:<4}{t.reset} " f"{indentchunk * len(markstack)}" - f"{opcode_color}{opcode.name}{t.reset}" + f"{opcode_color}{opcode.name}{opcode_reset}" ) maxproto = max(maxproto, opcode.proto) From 9db621e14c4026b309210dfe1138ade2faeefbe5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:47:15 +0300 Subject: [PATCH 4/6] Improve readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/pickletools.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Lib/pickletools.py b/Lib/pickletools.py index 3ef93e7dfa5123..d11e2727c0297d 100644 --- a/Lib/pickletools.py +++ b/Lib/pickletools.py @@ -2582,10 +2582,11 @@ def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): stack.extend(after) - print( - f"highest protocol among opcodes = {t.proto}{maxproto}{t.reset}", - file=out, - ) + print( + "highest protocol among opcodes =", + f"{t.proto}{maxproto}{t.reset}", + file=out, + ) if stack: raise ValueError("stack not empty after STOP: %r" % stack) From 2d232cd6f7be44c535057f4acd2865276456fe3b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:45:55 +0300 Subject: [PATCH 5/6] Simplify --- Lib/pickletools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/pickletools.py b/Lib/pickletools.py index d11e2727c0297d..198e85ef17efe3 100644 --- a/Lib/pickletools.py +++ b/Lib/pickletools.py @@ -2470,7 +2470,7 @@ def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): indentchunk = ' ' * indentlevel errormsg = None annocol = annotate # column hint for annotations - t = get_theme(tty_file=out if out is not None else sys.stdout).pickletools + t = get_theme(tty_file=out).pickletools for opcode, arg, pos in genops(pickle): if pos is not None: print(f"{t.position}{pos:5d}:{t.reset}", end=' ', file=out) From b720bd0d65087ee25c24f3b8325e5a12e93d5b38 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:47:02 +0300 Subject: [PATCH 6/6] Fix GH web editor's weird line endings --- Lib/pickletools.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/pickletools.py b/Lib/pickletools.py index 198e85ef17efe3..e2d5645069ebb6 100644 --- a/Lib/pickletools.py +++ b/Lib/pickletools.py @@ -2582,11 +2582,11 @@ def dis(pickle, out=None, memo=None, indentlevel=4, annotate=0): stack.extend(after) - print( - "highest protocol among opcodes =", - f"{t.proto}{maxproto}{t.reset}", - file=out, - ) + print( + "highest protocol among opcodes =", + f"{t.proto}{maxproto}{t.reset}", + file=out, + ) if stack: raise ValueError("stack not empty after STOP: %r" % stack)