diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index a1a8415482b97a..a99379c8ecb472 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -295,6 +295,15 @@ concurrent.futures (Contributed by xzmeng and Serhiy Storchaka in :gh:`108518`.) +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`.) + + encodings --------- diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 5f44a3aa05eb8f..8f43a04181f765 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -217,6 +217,33 @@ 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 + location_info: str = ANSIColors.MAGENTA + + opname: str | None = ANSIColors.BLUE + opname_with_label: str | None = ANSIColors.GREEN + + arg: str = ANSIColors.CYAN + + load_opname: str = ANSIColors.BLUE + pop_opname: str = ANSIColors.BLUE + + reset: str = ANSIColors.RESET + + def color_from_opname(self, opname: str) -> str: + if opname.startswith("LOAD_"): + return self.load_opname + + if opname.startswith("POP_"): + return self.pop_opname + + return self.opname or self.reset + @dataclass(frozen=True, kw_only=True) class FancyCompleter(ThemeSection): # functions and methods @@ -478,6 +505,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 +524,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 +546,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 +572,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 318729091fa098..0e67230a2a8575 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 @@ -503,11 +507,11 @@ def print_instruction_line(self, instr, mark_as_current): # reporting positions instead of just line numbers if instr_positions := instr.positions: if all(p is None for p in instr_positions): - positions_str = _NO_LINENO + positions_str = f"{theme.location_info}{_NO_LINENO}{theme.reset}" else: ps = tuple('?' if p is None else p for p in instr_positions) positions_str = f"{ps[0]}:{ps[2]}-{ps[1]}:{ps[3]}" - fields.append(f'{positions_str:{lineno_width}}') + fields.append(f'{theme.location_info}{positions_str:{lineno_width}}{theme.reset}') else: fields.append(' ' * lineno_width) else: @@ -515,13 +519,14 @@ def print_instruction_line(self, instr, mark_as_current): lineno_fmt = "%%%dd" if instr.line_number is not None else "%%%ds" lineno_fmt = lineno_fmt % lineno_width lineno = _NO_LINENO if instr.line_number is None else instr.line_number - fields.append(lineno_fmt % lineno) + fields.append(f'{theme.location_info}{lineno_fmt % lineno}{theme.reset}') else: fields.append(' ' * lineno_width) # 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,19 @@ def print_instruction_line(self, instr, mark_as_current): else: fields.append(' ') # Column: Opcode name - fields.append(instr.opname.ljust(_OPNAME_WIDTH)) + if (instr.label is not None) and (theme.opname_with_label is not None): + opname_color = theme.opname_with_label + else: + opname_color = theme.color_from_opname(instr.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 +558,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 +566,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 +597,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 +636,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 +873,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 99a11e99d56485..135dc2df9b1864 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 3e562a26ad586a..2bffe023c1c02c 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.load_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) + + cases = ( + ("RESUME", theme.opname), + ("LOAD_FAST", theme.load_opname), + ("GET_ITER", theme.opname), + ("STORE_FAST", theme.opname), + ("JUMP_BACKWARD", theme.opname), + ("POP_ITER", theme.pop_opname), + ("LOAD_COMMON_CONSTANT", theme.load_opname), + ("RETURN_VALUE", theme.opname), + ) + + for opname, expected_color in cases: + self.assertOpColoredAs(out, opname, expected_color) + + 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 00000000000000..7640069e873424 --- /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.