diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 063755e1eadcb53..25b8781d77f551a 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -281,6 +281,15 @@ ctypes (Contributed by Peter Bierma in :gh:`153903`.) +dis +--- + +* :func:`dis.dis` supports colored output by default, which can also be + :ref:`controlled ` through ``NO_COLOR=1`` + environment variable. + (Contributed by Abduaziz Ziyodov in :gh:`144207`.) + + concurrent.futures ------------------ diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 5f44a3aa05eb8f6..8cde1565f09d638 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -217,6 +217,21 @@ class Difflib(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=True, kw_only=True) +class Dis(ThemeSection): + disassembly_header: str = ANSIColors.GREEN + + jump_target: str = ANSIColors.GREEN + exception_label: str = ANSIColors.GREEN + + opname: str = ANSIColors.BLUE + opname_with_label: str = ANSIColors.GREEN + + arg: str = ANSIColors.YELLOW + + reset: str = ANSIColors.RESET + + @dataclass(frozen=True, kw_only=True) class FancyCompleter(ThemeSection): # functions and methods @@ -478,6 +493,7 @@ class Theme: tokenize: Tokenize = field(default_factory=Tokenize) traceback: Traceback = field(default_factory=Traceback) unittest: Unittest = field(default_factory=Unittest) + dis: Dis = field(default_factory=Dis) def copy_with( self, @@ -496,6 +512,7 @@ def copy_with( tokenize: Tokenize | None = None, traceback: Traceback | None = None, unittest: Unittest | None = None, + dis: Dis | None = None ) -> Self: """Return a new Theme based on this instance with some sections replaced. @@ -517,6 +534,7 @@ def copy_with( tokenize=tokenize or self.tokenize, traceback=traceback or self.traceback, unittest=unittest or self.unittest, + dis=dis or self.dis ) @classmethod @@ -542,6 +560,7 @@ def no_colors(cls) -> Self: tokenize=Tokenize.no_colors(), traceback=Traceback.no_colors(), unittest=Unittest.no_colors(), + dis=Dis.no_colors(), ) diff --git a/Lib/dis.py b/Lib/dis.py index 318729091fa098c..5ece6113a4cf6d5 100644 --- a/Lib/dis.py +++ b/Lib/dis.py @@ -442,6 +442,9 @@ def __str__(self): formatter.print_instruction(self, False) return output.getvalue() +def _get_dis_theme(): + from _colorize import get_theme + return get_theme().dis class Formatter: @@ -486,6 +489,7 @@ def print_instruction(self, instr, mark_as_current=False): def print_instruction_line(self, instr, mark_as_current): """Format instruction details for inclusion in disassembly output.""" + theme = _get_dis_theme() lineno_width = self.lineno_width offset_width = self.offset_width label_width = self.label_width @@ -521,7 +525,8 @@ def print_instruction_line(self, instr, mark_as_current): # Column: Label if instr.label is not None: lbl = f"L{instr.label}:" - fields.append(f"{lbl:>{label_width}}") + padded = f"{lbl:>{label_width}}" + fields.append(f"{theme.jump_target}{padded}{theme.reset}") else: fields.append(' ' * label_width) # Column: Instruction offset from start of code sequence @@ -533,14 +538,15 @@ def print_instruction_line(self, instr, mark_as_current): else: fields.append(' ') # Column: Opcode name - fields.append(instr.opname.ljust(_OPNAME_WIDTH)) + opname_color = theme.opname_with_label if instr.label is not None else theme.opname + fields.append(f"{opname_color}{instr.opname.ljust(_OPNAME_WIDTH)}{theme.reset}") # Column: Opcode argument if instr.arg is not None: # If opname is longer than _OPNAME_WIDTH, we allow it to overflow into # the space reserved for oparg. This results in fewer misaligned opargs # in the disassembly output. opname_excess = max(0, len(instr.opname) - _OPNAME_WIDTH) - fields.append(repr(instr.arg).rjust(_OPARG_WIDTH - opname_excess)) + fields.append(f"{theme.arg}{repr(instr.arg)}{theme.reset}".rjust(_OPARG_WIDTH - opname_excess)) # Column: Opcode argument details if instr.argrepr: fields.append('(' + instr.argrepr + ')') @@ -548,6 +554,7 @@ def print_instruction_line(self, instr, mark_as_current): def print_exception_table(self, exception_entries): file = self.file + theme = _get_dis_theme() if exception_entries: print("ExceptionTable:", file=file) for entry in exception_entries: @@ -555,7 +562,12 @@ def print_exception_table(self, exception_entries): start = entry.start_label end = entry.end_label target = entry.target_label - print(f" L{start} to L{end} -> L{target} [{entry.depth}]{lasti}", file=file) + print( + f" {theme.exception_label}L{start}{theme.reset} to " + f"{theme.exception_label}L{end}{theme.reset} " + f"-> {theme.exception_label}L{target}{theme.reset} [{entry.depth}]{lasti}", + file=file, + ) class ArgResolver: @@ -581,6 +593,7 @@ def get_label_for_offset(self, offset): return self.labels_map.get(offset, None) def get_argval_argrepr(self, op, arg, offset): + theme = _get_dis_theme() get_name = None if self.names is None else self.names.__getitem__ argval = None argrepr = '' @@ -619,7 +632,7 @@ def get_argval_argrepr(self, op, arg, offset): lbl = self.get_label_for_offset(argval) assert lbl is not None preposition = "from" if deop == END_ASYNC_FOR else "to" - argrepr = f"{preposition} L{lbl}" + argrepr = f"{preposition} {theme.jump_target}L{lbl}{theme.reset}" elif deop in (LOAD_FAST_LOAD_FAST, LOAD_FAST_BORROW_LOAD_FAST_BORROW, STORE_FAST_LOAD_FAST, STORE_FAST_STORE_FAST): arg1 = arg >> 4 arg2 = arg & 15 @@ -856,10 +869,11 @@ def _disassemble_recursive(co, *, file=None, depth=None, show_caches=False, adap if depth is None or depth > 0: if depth is not None: depth = depth - 1 + theme = _get_dis_theme() for x in co.co_consts: if hasattr(x, 'co_code'): print(file=file) - print("Disassembly of %r:" % (x,), file=file) + print(f"Disassembly of {theme.disassembly_header}{x!r}{theme.reset}:", file=file) _disassemble_recursive( x, file=file, depth=depth, show_caches=show_caches, adaptive=adaptive, show_offsets=show_offsets, diff --git a/Lib/test/test_compiler_assemble.py b/Lib/test/test_compiler_assemble.py index 99a11e99d564852..135dc2df9b1864d 100644 --- a/Lib/test/test_compiler_assemble.py +++ b/Lib/test/test_compiler_assemble.py @@ -4,7 +4,7 @@ import types from test.support.bytecode_helper import AssemblerTestCase - +from test.support import force_not_colorized # Tests for the code-object creation stage of the compiler. @@ -115,6 +115,7 @@ def inner(): self.assemble_test(instructions, metadata, expected) + @force_not_colorized def test_exception_table(self): metadata = { 'filename' : 'exc.py', diff --git a/Lib/test/test_dis.py b/Lib/test/test_dis.py index 3e562a26ad586aa..54dd76bc5415c49 100644 --- a/Lib/test/test_dis.py +++ b/Lib/test/test_dis.py @@ -13,10 +13,11 @@ import textwrap import types import unittest -from test.support import (captured_stdout, requires_debug_ranges, - requires_specialization, cpython_only, - os_helper, import_helper, reset_code, - requires_jit_enabled) +from test.support import (captured_stdout, force_not_colorized_test_class, + force_colorized_test_class, requires_debug_ranges, + requires_specialization, cpython_only, os_helper, + import_helper, reset_code, requires_jit_enabled) + from test.support.bytecode_helper import BytecodeTestCase @@ -37,6 +38,8 @@ def _error(): TRACEBACK_CODE = get_tb().tb_frame.f_code +theme = dis._get_dis_theme() + class _C: def __init__(self, x): self.x = x == 1 @@ -997,6 +1000,7 @@ def do_disassembly_compare(self, got, expected): self.assertEqual(got, expected) +@force_not_colorized_test_class class DisTests(DisTestBase): maxDiff = None @@ -2032,6 +2036,7 @@ def assertInstructionsEqual(self, instrs_1, instrs_2, /): instrs_2 = [instr_2._replace(positions=None, cache_info=None) for instr_2 in instrs_2] self.assertEqual(instrs_1, instrs_2) +@force_not_colorized_test_class class InstructionTests(InstructionTestCase): def __init__(self, *args): @@ -2353,6 +2358,7 @@ def test_cache_offset_and_end_offset(self): # get_instructions has its own tests above, so can rely on it to validate # the object oriented API +@force_not_colorized_test_class class BytecodeTests(InstructionTestCase, DisTestBase): def test_instantiation(self): @@ -2486,6 +2492,7 @@ def func(): self.assertEqual(offsets, [0, 4]) +@force_not_colorized_test_class class TestDisTraceback(DisTestBase): def setUp(self) -> None: try: # We need to clean up existing tracebacks @@ -2523,6 +2530,7 @@ def test_distb_explicit_arg(self): self.do_disassembly_compare(self.get_disassembly(tb), dis_traceback) +@force_not_colorized_test_class class TestDisTracebackWithFile(TestDisTraceback): # Run the `distb` tests again, using the file arg instead of print def get_disassembly(self, tb): @@ -2557,6 +2565,7 @@ def _unroll_caches_as_Instructions(instrs, show_caches=False): False, None, None, instr.positions) +@force_not_colorized_test_class class TestDisCLI(unittest.TestCase): def setUp(self): @@ -2672,5 +2681,142 @@ def test_specialized_code(self): self.check_output(source, expect, flag) +@force_colorized_test_class +class DisColoredTests(unittest.TestCase): + def get_colored_output(self, func): + output = io.StringIO() + + with contextlib.redirect_stdout(output): + dis.dis(func) + + return output.getvalue() + + def _check_colored(self, output, opname, color, as_not_colored): + # allow spaces, ANSI colors etc. + inter_word_pattern = r"(?:\s|\x1b\[[0-9;]*m)*" + + tokens = opname.split() + escaped_tokens = [re.escape(token) for token in tokens] + joined_opname = inter_word_pattern.join(escaped_tokens) + + pattern = re.escape(color) + inter_word_pattern + joined_opname + + if as_not_colored: + self.assertNotRegex( + output, + pattern, + f"{opname} should NOT be colored with {color!r}", + ) + else: + self.assertRegex( + output, pattern, f"{opname} should be colored with {color!r}" + ) + + def assertOpColoredAs(self, output, opname, color): + self._check_colored(output, opname, color, as_not_colored=False) + + def assertOpNotColoredAs(self, output, opname, wrong_color): + self._check_colored(output, opname, wrong_color, as_not_colored=True) + + def test_opname_and_arg_colored(self): + def f(a): + return a + + out = self.get_colored_output(f) + self.assertOpColoredAs(out, "LOAD_FAST_BORROW", theme.opname) + self.assertOpColoredAs(out, "RETURN_VALUE", theme.opname) + self.assertOpColoredAs(out, "0", theme.arg) + + def test_control_flow_ops_colored(self): + def f(a): + for _ in a: + pass + + out = self.get_colored_output(f) + + self.assertOpNotColoredAs(out, "FOR_ITER", theme.opname) + self.assertOpNotColoredAs(out, "END_FOR", theme.opname) + + self.assertOpColoredAs(out, "FOR_ITER", theme.opname_with_label) + self.assertOpColoredAs(out, "END_FOR", theme.opname_with_label) + + opnames = ( + "RESUME", + "LOAD_FAST", + "GET_ITER", + "STORE_FAST", + "JUMP_BACKWARD", + "POP_ITER", + "LOAD_COMMON_CONSTANT", + "RETURN_VALUE", + ) + + for opname in opnames: + self.assertOpColoredAs(out, opname, theme.opname) + + def test_jump_targets_colored(self): + # sample code from: + # https://github.com/python/cpython/pull/144208#issuecomment-5375286176 + def f(a, c): + _t2.d if ( + _t2 := ( + _t1 + if (_t1 := a.b if a is not None else None) is not None + else c + ) + ) is not None else None + + out = self.get_colored_output(f) + + for n in range(1, 6): + self.assertOpColoredAs(out, f"L{n}:", theme.jump_target) + self.assertIn(f"(to {theme.jump_target}L{n}{theme.reset})", out) + + cases = ( + "L1: LOAD_COMMON_CONSTANT", + "L2: COPY", + "L3: LOAD_FAST", + "L4: COPY", + "L5: LOAD_COMMON_CONSTANT", + ) + + for part in cases: + self.assertOpColoredAs(out, part, theme.jump_target) + + def test_exception_table_colored(self): + def f(a): + try: + a + except Exception: + pass + else: + return a + + out = self.get_colored_output(f) + + cases = ( + ("L1", "L2", "L3"), + ("L3", "L4", "L8"), + ("L5", "L6", "L8"), + ("L7", "L8", "L8"), + ) + + def assertExceptionTableRow(pairs, out): + p1, p2, p3 = pairs + part = f"{theme.jump_target}{p1}{theme.reset} to {theme.jump_target}{p2}{theme.reset} -> {theme.jump_target}{p3}{theme.reset}" + self.assertIn(part, out) + + for pairs in cases: + assertExceptionTableRow(pairs, out) + + cases = ( + "L3: PUSH_EXC_INFO", + "L6: POP_EXCEPT", + "L7: RERAISE", + ) + + for part in cases: + self.assertOpColoredAs(out, part, theme.jump_target) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-01-25-15-26-23.gh-issue-144207.G2c_qd.rst b/Misc/NEWS.d/next/Library/2026-01-25-15-26-23.gh-issue-144207.G2c_qd.rst new file mode 100644 index 000000000000000..7640069e8734241 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-01-25-15-26-23.gh-issue-144207.G2c_qd.rst @@ -0,0 +1,3 @@ +:func:`dis.dis` supports colored output by default which can also be +:ref:`controlled ` through ``NO_COLOR=1`` +environment variable. Contributed by Abduaziz Ziyodov.