From e3feeaef4014b6c31c1fe17eef9df8c07466a377 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 12:44:42 +0100 Subject: [PATCH 01/10] Add color to `ast.dump` --- Doc/library/ast.rst | 10 +++++++- Doc/whatsnew/3.15.rst | 10 ++++++++ Lib/_colorize.py | 14 +++++++++++ Lib/ast.py | 44 +++++++++++++++++++++++++++++++---- Lib/test/test_ast/test_ast.py | 13 +++++++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/Doc/library/ast.rst b/Doc/library/ast.rst index 9b4e7ae18348f1..4a0c43bd9ae915 100644 --- a/Doc/library/ast.rst +++ b/Doc/library/ast.rst @@ -2480,7 +2480,7 @@ and classes for traversing abstract syntax trees: node = YourTransformer().visit(node) -.. function:: dump(node, annotate_fields=True, include_attributes=False, *, indent=None, show_empty=False) +.. function:: dump(node, annotate_fields=True, include_attributes=False, *, color=True, indent=None, show_empty=False) Return a formatted dump of the tree in *node*. This is mainly useful for debugging purposes. If *annotate_fields* is true (by default), @@ -2490,6 +2490,11 @@ and classes for traversing abstract syntax trees: numbers and column offsets are not dumped by default. If this is wanted, *include_attributes* can be set to true. + If *color* is ``True`` (the default), output will be syntax highlighted using + ANSI escape sequences, if the *stream* and :ref:`environment variables + ` permit. + If ``False``, colored output is always disabled. + If *indent* is a non-negative integer or string, then the tree will be pretty-printed with that indent level. An indent level of 0, negative, or ``""`` will only insert newlines. ``None`` (the default) @@ -2527,6 +2532,9 @@ and classes for traversing abstract syntax trees: .. versionchanged:: 3.15 Omit optional ``Load()`` values by default. + .. versionchanged:: next + Added the *color* parameter. + .. _ast-compiler-flags: diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index dbdd5de01700a3..6335f5a98d8305 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -692,6 +692,16 @@ array (Contributed by Sergey B Kirpichev in :gh:`146238`.) +ast +--- + +* Add *color* parameter to :func:`~ast.dump`. + If ``True`` (the default), output is highlighted in color, when the stream + and :ref:`environment variables ` permit. + If ``False``, colored output is always disabled. + (Contributed by Stan Ulbrych in :gh:`148981`.) + + base64 ------ diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 852ad38f08618e..35c3622619e967 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -189,6 +189,16 @@ class Argparse(ThemeSection): message: str = ANSIColors.MAGENTA +@dataclass(frozen=True, kw_only=True) +class Ast(ThemeSection): + node: str = ANSIColors.CYAN + field: str = ANSIColors.BLUE + string: str = ANSIColors.GREEN + number: str = ANSIColors.YELLOW + keyword: str = ANSIColors.BOLD_BLUE + reset: str = ANSIColors.RESET + + @dataclass(frozen=True, kw_only=True) class Difflib(ThemeSection): """A 'git diff'-like theme for `difflib.unified_diff`.""" @@ -405,6 +415,7 @@ class Theme: below. """ argparse: Argparse = field(default_factory=Argparse) + ast: Ast = field(default_factory=Ast) difflib: Difflib = field(default_factory=Difflib) fancycompleter: FancyCompleter = field(default_factory=FancyCompleter) http_server: HttpServer = field(default_factory=HttpServer) @@ -418,6 +429,7 @@ def copy_with( self, *, argparse: Argparse | None = None, + ast: Ast | None = None, difflib: Difflib | None = None, fancycompleter: FancyCompleter | None = None, http_server: HttpServer | None = None, @@ -434,6 +446,7 @@ def copy_with( """ return type(self)( argparse=argparse or self.argparse, + ast=ast or self.ast, difflib=difflib or self.difflib, fancycompleter=fancycompleter or self.fancycompleter, http_server=http_server or self.http_server, @@ -454,6 +467,7 @@ def no_colors(cls) -> Self: """ return cls( argparse=Argparse.no_colors(), + ast=Ast.no_colors(), difflib=Difflib.no_colors(), fancycompleter=FancyCompleter.no_colors(), http_server=HttpServer.no_colors(), diff --git a/Lib/ast.py b/Lib/ast.py index d9743ba7ab40b1..2170802302b0b4 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -21,6 +21,9 @@ :license: Python License. """ from _ast import * +lazy import re +lazy import sys +lazy from _colorize import can_colorize, get_theme def parse(source, filename='', mode='exec', *, @@ -117,7 +120,7 @@ def _convert_literal(node): def dump( node, annotate_fields=True, include_attributes=False, *, - indent=None, show_empty=False, + color=True, indent=None, show_empty=False, ): """ Return a formatted dump of the tree in node. This is mainly useful for @@ -131,6 +134,9 @@ def dump( level. None (the default) selects the single line representation. If show_empty is False, then empty lists and fields that are None will be omitted from the output for better readability. + If color is true (the default), the result will be syntax highlighted + using ANSI escape sequences if the stream and environment variables permit. + If color is false, colored output is always disabled. """ def _format(node, level=0): if indent is not None: @@ -201,7 +207,38 @@ def _format(node, level=0): raise TypeError('expected AST, got %r' % node.__class__.__name__) if indent is not None and not isinstance(indent, str): indent = ' ' * indent - return _format(node)[0] + output = _format(node)[0] + if color: + if can_colorize(file=sys.stdout): + output = _colorize_dump(output, get_theme(tty_file=sys.stdout).ast) + return output + + +_color_pattern = None + +def _colorize_dump(output, theme): + global _color_pattern + if _color_pattern is None: + _color_pattern = re.compile(r""" + (?P[bB]?'(?:\\.|[^'\\])*'|[bB]?"(?:\\.|[^"\\])*") | + (?P\b(?:None|True|False|Ellipsis)\b) | + (?P[A-Za-z_]\w*)(?=\() | + (?P[a-z_]\w*)(?==) | + (?P-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?[jJ]?) + """, re.VERBOSE) + + color_map = { + "node": theme.node, + "field": theme.field, + "string": theme.string, + "number": theme.number, + "keyword": theme.keyword, + } + + def replace(match): + return f"{color_map[match.lastgroup]}{match.group()}{theme.reset}" + + return _color_pattern.sub(replace, output) def copy_location(new_node, old_node): @@ -337,8 +374,6 @@ def _splitlines_no_ff(source, maxlines=None): """ global _line_pattern if _line_pattern is None: - # lazily computed to speedup import time of `ast` - import re _line_pattern = re.compile(r"(.*?(?:\r\n|\n|\r|$))") lines = [] @@ -640,7 +675,6 @@ def unparse(ast_obj): def main(args=None): import argparse - import sys parser = argparse.ArgumentParser(color=True) parser.add_argument('infile', nargs='?', default='-', diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index f29f98beb2d048..48a46277feba84 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1450,6 +1450,7 @@ def test_replace_non_str_kwarg(self): node.__replace__(**{object(): "y"}) +@support.force_not_colorized_test_class class ASTHelpers_Test(unittest.TestCase): maxDiff = None @@ -1705,6 +1706,16 @@ def check_text(code, empty, full, **kwargs): full="Module(body=[Import(names=[alias(name='_ast', asname='ast')], is_lazy=0), ImportFrom(module='module', names=[alias(name='sub')], level=0, is_lazy=0)], type_ignores=[])", ) + def test_dump_with_color(self): + node = ast.parse("x = 1") + + with support.force_color(True): + self.assertNotIn("\x1b[", ast.dump(node, color=False)) + self.assertIn("\x1b[", ast.dump(node, color=True)) + + with support.force_color(False): + self.assertNotIn("\x1b[", ast.dump(node, color=True)) + def test_copy_location(self): src = ast.parse('1 + 1', mode='eval') src.body.right = ast.copy_location(ast.Constant(2), src.body.right) @@ -3415,6 +3426,7 @@ def test_subinterpreter(self): self.assertEqual(res, 0) +@support.force_not_colorized_test_class class CommandLineTests(unittest.TestCase): def setUp(self): self.filename = tempfile.mktemp() @@ -3674,6 +3686,7 @@ def test_show_empty_flag(self): self.check_output(source, expect, '--show-empty') +@support.force_not_colorized_test_class class ASTOptimizationTests(unittest.TestCase): def wrap_expr(self, expr): return ast.Module(body=[ast.Expr(value=expr)]) From fd0ec73cb48692d827fe3f2d6a0e82ef405aa346 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 12:51:00 +0100 Subject: [PATCH 02/10] Add the blurb --- .../next/Library/2026-04-25-12-50-46.gh-issue-148981.YMM4Y9.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-04-25-12-50-46.gh-issue-148981.YMM4Y9.rst diff --git a/Misc/NEWS.d/next/Library/2026-04-25-12-50-46.gh-issue-148981.YMM4Y9.rst b/Misc/NEWS.d/next/Library/2026-04-25-12-50-46.gh-issue-148981.YMM4Y9.rst new file mode 100644 index 00000000000000..e36c7745f4080a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-04-25-12-50-46.gh-issue-148981.YMM4Y9.rst @@ -0,0 +1 @@ +Add *color* parameter to :func:`ast.dump`. From 3bda2fe3316fc417619d4515abea44389242ee12 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 12:57:18 +0100 Subject: [PATCH 03/10] Make color=False by default --- Doc/library/ast.rst | 6 +++--- Doc/whatsnew/3.15.rst | 4 ++-- Lib/ast.py | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Doc/library/ast.rst b/Doc/library/ast.rst index 4a0c43bd9ae915..3fc0bfbf437960 100644 --- a/Doc/library/ast.rst +++ b/Doc/library/ast.rst @@ -2480,7 +2480,7 @@ and classes for traversing abstract syntax trees: node = YourTransformer().visit(node) -.. function:: dump(node, annotate_fields=True, include_attributes=False, *, color=True, indent=None, show_empty=False) +.. function:: dump(node, annotate_fields=True, include_attributes=False, *, color=False, indent=None, show_empty=False) Return a formatted dump of the tree in *node*. This is mainly useful for debugging purposes. If *annotate_fields* is true (by default), @@ -2490,10 +2490,10 @@ and classes for traversing abstract syntax trees: numbers and column offsets are not dumped by default. If this is wanted, *include_attributes* can be set to true. - If *color* is ``True`` (the default), output will be syntax highlighted using + If *color* is ``True``, output will be syntax highlighted using ANSI escape sequences, if the *stream* and :ref:`environment variables ` permit. - If ``False``, colored output is always disabled. + If ``False`` (the default), colored output is always disabled. If *indent* is a non-negative integer or string, then the tree will be pretty-printed with that indent level. An indent level diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 6335f5a98d8305..d9fec2ae101b57 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -696,9 +696,9 @@ ast --- * Add *color* parameter to :func:`~ast.dump`. - If ``True`` (the default), output is highlighted in color, when the stream + If ``True``, output is highlighted in color, when the stream and :ref:`environment variables ` permit. - If ``False``, colored output is always disabled. + If ``False`` (the default), colored output is always disabled. (Contributed by Stan Ulbrych in :gh:`148981`.) diff --git a/Lib/ast.py b/Lib/ast.py index 2170802302b0b4..5945ceb38f9225 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -120,7 +120,7 @@ def _convert_literal(node): def dump( node, annotate_fields=True, include_attributes=False, *, - color=True, indent=None, show_empty=False, + color=False, indent=None, show_empty=False, ): """ Return a formatted dump of the tree in node. This is mainly useful for @@ -134,9 +134,9 @@ def dump( level. None (the default) selects the single line representation. If show_empty is False, then empty lists and fields that are None will be omitted from the output for better readability. - If color is true (the default), the result will be syntax highlighted + If color is true, the result will be syntax highlighted using ANSI escape sequences if the stream and environment variables permit. - If color is false, colored output is always disabled. + If color is false (the default), colored output is always disabled. """ def _format(node, level=0): if indent is not None: @@ -721,7 +721,7 @@ def main(args=None): tree = parse(source, name, args.mode, type_comments=args.no_type_comments, feature_version=feature_version, optimize=args.optimize) - print(dump(tree, include_attributes=args.include_attributes, + print(dump(tree, include_attributes=args.include_attributes, color=True, indent=args.indent, show_empty=args.show_empty)) if __name__ == '__main__': From 65fbf801e9f73248bb167fed9a7a4ef93c987644 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 13:19:13 +0100 Subject: [PATCH 04/10] Rewrite to add colors directly in `_format()` --- Doc/library/ast.rst | 5 ++- Doc/whatsnew/3.15.rst | 4 +-- Lib/_colorize.py | 1 + Lib/ast.py | 63 ++++++++++++----------------------- Lib/test/test_ast/test_ast.py | 10 ++---- 5 files changed, 28 insertions(+), 55 deletions(-) diff --git a/Doc/library/ast.rst b/Doc/library/ast.rst index 3fc0bfbf437960..9ff77c1a79eaf3 100644 --- a/Doc/library/ast.rst +++ b/Doc/library/ast.rst @@ -2490,9 +2490,8 @@ and classes for traversing abstract syntax trees: numbers and column offsets are not dumped by default. If this is wanted, *include_attributes* can be set to true. - If *color* is ``True``, output will be syntax highlighted using - ANSI escape sequences, if the *stream* and :ref:`environment variables - ` permit. + If *color* is ``True``, the returned string is syntax highlighted using + ANSI escape sequences. If ``False`` (the default), colored output is always disabled. If *indent* is a non-negative integer or string, then the tree will be diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index d9fec2ae101b57..de485dd03ad058 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -696,8 +696,8 @@ ast --- * Add *color* parameter to :func:`~ast.dump`. - If ``True``, output is highlighted in color, when the stream - and :ref:`environment variables ` permit. + If ``True``, the returned string is syntax highlighted using ANSI escape + sequences. If ``False`` (the default), colored output is always disabled. (Contributed by Stan Ulbrych in :gh:`148981`.) diff --git a/Lib/_colorize.py b/Lib/_colorize.py index 35c3622619e967..f9ee2caa9d091c 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -193,6 +193,7 @@ class Argparse(ThemeSection): class Ast(ThemeSection): node: str = ANSIColors.CYAN field: str = ANSIColors.BLUE + attribute: str = ANSIColors.GREY string: str = ANSIColors.GREEN number: str = ANSIColors.YELLOW keyword: str = ANSIColors.BOLD_BLUE diff --git a/Lib/ast.py b/Lib/ast.py index 5945ceb38f9225..feb5e574b566aa 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -134,10 +134,12 @@ def dump( level. None (the default) selects the single line representation. If show_empty is False, then empty lists and fields that are None will be omitted from the output for better readability. - If color is true, the result will be syntax highlighted - using ANSI escape sequences if the stream and environment variables permit. + If color is true, the returned string is syntax highlighted using ANSI + escape sequences. If color is false (the default), colored output is always disabled. """ + theme = get_theme(force_color=color, force_no_color=not color).ast + def _format(node, level=0): if indent is not None: level += 1 @@ -166,13 +168,13 @@ def _format(node, level=0): field_type = cls._field_types.get(name, object) if getattr(field_type, '__origin__', ...) is list: if not keywords: - args_buffer.append(repr(value)) + args_buffer.append(_format(value, level)[0]) continue elif isinstance(value, Load): field_type = cls._field_types.get(name, object) if field_type is expr_context: if not keywords: - args_buffer.append(repr(value)) + args_buffer.append(_format(value, level)[0]) continue if not keywords: args.extend(args_buffer) @@ -180,7 +182,7 @@ def _format(node, level=0): value, simple = _format(value, level) allsimple = allsimple and simple if keywords: - args.append('%s=%s' % (name, value)) + args.append(f'{theme.field}{name}{theme.reset}={value}') else: args.append(value) if include_attributes and node._attributes: @@ -193,52 +195,28 @@ def _format(node, level=0): continue value, simple = _format(value, level) allsimple = allsimple and simple - args.append('%s=%s' % (name, value)) + args.append(f'{theme.attribute}{name}{theme.reset}={value}') + cls_name = f'{theme.node}{cls.__name__}{theme.reset}' if allsimple and len(args) <= 3: - return '%s(%s)' % (node.__class__.__name__, ', '.join(args)), not args - return '%s(%s%s)' % (node.__class__.__name__, prefix, sep.join(args)), False + return f'{cls_name}({", ".join(args)})', not args + return f'{cls_name}({prefix}{sep.join(args)})', False elif isinstance(node, list): if not node: return '[]', True - return '[%s%s]' % (prefix, sep.join(_format(x, level)[0] for x in node)), False + return f'[{prefix}{sep.join(_format(x, level)[0] for x in node)}]', False + if isinstance(node, bool) or node is None or node is Ellipsis: + return f'{theme.keyword}{node!r}{theme.reset}', True + if isinstance(node, (int, float, complex)): + return f'{theme.number}{node!r}{theme.reset}', True + if isinstance(node, (str, bytes)): + return f'{theme.string}{node!r}{theme.reset}', True return repr(node), True if not isinstance(node, AST): raise TypeError('expected AST, got %r' % node.__class__.__name__) if indent is not None and not isinstance(indent, str): indent = ' ' * indent - output = _format(node)[0] - if color: - if can_colorize(file=sys.stdout): - output = _colorize_dump(output, get_theme(tty_file=sys.stdout).ast) - return output - - -_color_pattern = None - -def _colorize_dump(output, theme): - global _color_pattern - if _color_pattern is None: - _color_pattern = re.compile(r""" - (?P[bB]?'(?:\\.|[^'\\])*'|[bB]?"(?:\\.|[^"\\])*") | - (?P\b(?:None|True|False|Ellipsis)\b) | - (?P[A-Za-z_]\w*)(?=\() | - (?P[a-z_]\w*)(?==) | - (?P-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?[jJ]?) - """, re.VERBOSE) - - color_map = { - "node": theme.node, - "field": theme.field, - "string": theme.string, - "number": theme.number, - "keyword": theme.keyword, - } - - def replace(match): - return f"{color_map[match.lastgroup]}{match.group()}{theme.reset}" - - return _color_pattern.sub(replace, output) + return _format(node)[0] def copy_location(new_node, old_node): @@ -721,7 +699,8 @@ def main(args=None): tree = parse(source, name, args.mode, type_comments=args.no_type_comments, feature_version=feature_version, optimize=args.optimize) - print(dump(tree, include_attributes=args.include_attributes, color=True, + print(dump(tree, include_attributes=args.include_attributes, + color=can_colorize(file=sys.stdout), indent=args.indent, show_empty=args.show_empty)) if __name__ == '__main__': diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index 48a46277feba84..b79230c638909a 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1450,7 +1450,6 @@ def test_replace_non_str_kwarg(self): node.__replace__(**{object(): "y"}) -@support.force_not_colorized_test_class class ASTHelpers_Test(unittest.TestCase): maxDiff = None @@ -1708,13 +1707,8 @@ def check_text(code, empty, full, **kwargs): def test_dump_with_color(self): node = ast.parse("x = 1") - - with support.force_color(True): - self.assertNotIn("\x1b[", ast.dump(node, color=False)) - self.assertIn("\x1b[", ast.dump(node, color=True)) - - with support.force_color(False): - self.assertNotIn("\x1b[", ast.dump(node, color=True)) + self.assertNotIn("\x1b[", ast.dump(node, color=False)) + self.assertIn("\x1b[", ast.dump(node, color=True)) def test_copy_location(self): src = ast.parse('1 + 1', mode='eval') From e71c217377df239ab937250e97aeb2c10d917281 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 13:23:31 +0100 Subject: [PATCH 05/10] Revert some leftovers from the old --- Lib/ast.py | 5 +++-- Lib/test/test_ast/test_ast.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/ast.py b/Lib/ast.py index feb5e574b566aa..df05f9d6ac9d4e 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -21,8 +21,6 @@ :license: Python License. """ from _ast import * -lazy import re -lazy import sys lazy from _colorize import can_colorize, get_theme @@ -352,6 +350,8 @@ def _splitlines_no_ff(source, maxlines=None): """ global _line_pattern if _line_pattern is None: + # lazily computed to speedup import time of `ast` + import re _line_pattern = re.compile(r"(.*?(?:\r\n|\n|\r|$))") lines = [] @@ -653,6 +653,7 @@ def unparse(ast_obj): def main(args=None): import argparse + import sys parser = argparse.ArgumentParser(color=True) parser.add_argument('infile', nargs='?', default='-', diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index b79230c638909a..fbab843d128efd 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -3680,7 +3680,6 @@ def test_show_empty_flag(self): self.check_output(source, expect, '--show-empty') -@support.force_not_colorized_test_class class ASTOptimizationTests(unittest.TestCase): def wrap_expr(self, expr): return ast.Module(body=[ast.Expr(value=expr)]) From 1e59cad360b6ccf6a5246e5c5eba541ff0723886 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 13:30:01 +0100 Subject: [PATCH 06/10] Expand test --- Lib/test/test_ast/test_ast.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index fbab843d128efd..75d553e6f7778f 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1707,9 +1707,14 @@ def check_text(code, empty, full, **kwargs): def test_dump_with_color(self): node = ast.parse("x = 1") + self.assertNotIn("\x1b[", ast.dump(node)) self.assertNotIn("\x1b[", ast.dump(node, color=False)) self.assertIn("\x1b[", ast.dump(node, color=True)) + node = ast.Constant(value="\x1b[31m") + self.assertEqual(ast.dump(node), "Constant(value='\\x1b[31m')") + self.assertIn("'\\x1b[31m'", ast.dump(node, color=True)) + def test_copy_location(self): src = ast.parse('1 + 1', mode='eval') src.body.right = ast.copy_location(ast.Constant(2), src.body.right) From 3adc040fa3f4669f4752f9bea8ae9d2b8d3053f7 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 13:50:40 +0100 Subject: [PATCH 07/10] Split docstring, avoid recursion --- Lib/ast.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/Lib/ast.py b/Lib/ast.py index df05f9d6ac9d4e..0a944d4703e903 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -122,19 +122,26 @@ def dump( ): """ Return a formatted dump of the tree in node. This is mainly useful for - debugging purposes. If annotate_fields is true (by default), + debugging purposes. + + If annotate_fields is true (by default), the returned string will show the names and the values for fields. If annotate_fields is false, the result string will be more compact by - omitting unambiguous field names. Attributes such as line - numbers and column offsets are not dumped by default. If this is wanted, - include_attributes can be set to true. If indent is a non-negative + omitting unambiguous field names. + + Attributes such as line numbers and column offsets are not dumped by default. + If this is wanted, include_attributes can be set to true. + + If color is true, the returned string is syntax highlighted using ANSI + escape sequences. + If color is false (the default), colored output is always disabled. + + If indent is a non-negative integer or string, then the tree will be pretty-printed with that indent level. None (the default) selects the single line representation. + If show_empty is False, then empty lists and fields that are None will be omitted from the output for better readability. - If color is true, the returned string is syntax highlighted using ANSI - escape sequences. - If color is false (the default), colored output is always disabled. """ theme = get_theme(force_color=color, force_no_color=not color).ast @@ -166,13 +173,15 @@ def _format(node, level=0): field_type = cls._field_types.get(name, object) if getattr(field_type, '__origin__', ...) is list: if not keywords: - args_buffer.append(_format(value, level)[0]) + args_buffer.append('[]') continue elif isinstance(value, Load): field_type = cls._field_types.get(name, object) if field_type is expr_context: if not keywords: - args_buffer.append(_format(value, level)[0]) + args_buffer.append( + f'{theme.node}{type(value).__name__}' + f'{theme.reset}()') continue if not keywords: args.extend(args_buffer) From 7b3c72ab6de3e514214f2f110e50fefd88e8a961 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 13:56:36 +0100 Subject: [PATCH 08/10] Keep that one as `repr(value)` --- Lib/ast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/ast.py b/Lib/ast.py index 0a944d4703e903..c4f82b3e3de56b 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -173,7 +173,7 @@ def _format(node, level=0): field_type = cls._field_types.get(name, object) if getattr(field_type, '__origin__', ...) is list: if not keywords: - args_buffer.append('[]') + args_buffer.append(repr(value)) continue elif isinstance(value, Load): field_type = cls._field_types.get(name, object) From 997e18c0496d0ee4ba44d1abee8b87b9ed78b954 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 14:03:43 +0100 Subject: [PATCH 09/10] Further refinement to docstring and no more fstring :-( --- Lib/ast.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Lib/ast.py b/Lib/ast.py index c4f82b3e3de56b..a95dee4015492b 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -124,21 +124,20 @@ def dump( Return a formatted dump of the tree in node. This is mainly useful for debugging purposes. - If annotate_fields is true (by default), - the returned string will show the names and the values for fields. - If annotate_fields is false, the result string will be more compact by - omitting unambiguous field names. + If annotate_fields is true (by default), the returned string will show the + names and the values for fields. If annotate_fields is false, the result + string will be more compact by omitting unambiguous field names. Attributes such as line numbers and column offsets are not dumped by default. If this is wanted, include_attributes can be set to true. If color is true, the returned string is syntax highlighted using ANSI - escape sequences. - If color is false (the default), colored output is always disabled. + escape sequences. If color is false (the default), colored output is always + disabled. - If indent is a non-negative - integer or string, then the tree will be pretty-printed with that indent - level. None (the default) selects the single line representation. + If indent is a non-negative integer or string, then the tree will be + pretty-printed with that indent level. If indent is None (the default), + the tree is dumped on a single line. If show_empty is False, then empty lists and fields that are None will be omitted from the output for better readability. @@ -210,7 +209,7 @@ def _format(node, level=0): elif isinstance(node, list): if not node: return '[]', True - return f'[{prefix}{sep.join(_format(x, level)[0] for x in node)}]', False + return '[%s%s]' % (prefix, sep.join(_format(x, level)[0] for x in node)), False if isinstance(node, bool) or node is None or node is Ellipsis: return f'{theme.keyword}{node!r}{theme.reset}', True if isinstance(node, (int, float, complex)): From 5d51438b6402388d7b3f97d09a828a4b3baebaa4 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 25 Apr 2026 15:42:36 +0100 Subject: [PATCH 10/10] Hugo's suggestions --- Doc/library/ast.rst | 4 ++++ Doc/whatsnew/3.15.rst | 4 ++++ Lib/ast.py | 22 +++++++++++----------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/Doc/library/ast.rst b/Doc/library/ast.rst index 9ff77c1a79eaf3..e23506768a7721 100644 --- a/Doc/library/ast.rst +++ b/Doc/library/ast.rst @@ -2591,6 +2591,10 @@ Command-line usage .. versionadded:: 3.9 +.. versionchanged:: next + The output is now syntax highlighted by default. This can be + :ref:`controlled using environment variables `. + The :mod:`!ast` module can be executed as a script from the command line. It is as simple as: diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index de485dd03ad058..ecbc8a66cd3086 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -701,6 +701,10 @@ ast If ``False`` (the default), colored output is always disabled. (Contributed by Stan Ulbrych in :gh:`148981`.) +* The :ref:`command-line ` output is now syntax highlighted by default. + This can be :ref:`controlled using environment variables `. + (Contributed by Stan Ulbrych in :gh:`148981`.) + base64 ------ diff --git a/Lib/ast.py b/Lib/ast.py index a95dee4015492b..ba4ee0197b85d2 100644 --- a/Lib/ast.py +++ b/Lib/ast.py @@ -142,7 +142,7 @@ def dump( If show_empty is False, then empty lists and fields that are None will be omitted from the output for better readability. """ - theme = get_theme(force_color=color, force_no_color=not color).ast + t = get_theme(force_color=color, force_no_color=not color).ast def _format(node, level=0): if indent is not None: @@ -179,8 +179,8 @@ def _format(node, level=0): if field_type is expr_context: if not keywords: args_buffer.append( - f'{theme.node}{type(value).__name__}' - f'{theme.reset}()') + f'{t.node}{type(value).__name__}' + f'{t.reset}()') continue if not keywords: args.extend(args_buffer) @@ -188,7 +188,7 @@ def _format(node, level=0): value, simple = _format(value, level) allsimple = allsimple and simple if keywords: - args.append(f'{theme.field}{name}{theme.reset}={value}') + args.append(f'{t.field}{name}{t.reset}={value}') else: args.append(value) if include_attributes and node._attributes: @@ -201,8 +201,8 @@ def _format(node, level=0): continue value, simple = _format(value, level) allsimple = allsimple and simple - args.append(f'{theme.attribute}{name}{theme.reset}={value}') - cls_name = f'{theme.node}{cls.__name__}{theme.reset}' + args.append(f'{t.attribute}{name}{t.reset}={value}') + cls_name = f'{t.node}{cls.__name__}{t.reset}' if allsimple and len(args) <= 3: return f'{cls_name}({", ".join(args)})', not args return f'{cls_name}({prefix}{sep.join(args)})', False @@ -211,11 +211,11 @@ def _format(node, level=0): return '[]', True return '[%s%s]' % (prefix, sep.join(_format(x, level)[0] for x in node)), False if isinstance(node, bool) or node is None or node is Ellipsis: - return f'{theme.keyword}{node!r}{theme.reset}', True + return f'{t.keyword}{node!r}{t.reset}', True if isinstance(node, (int, float, complex)): - return f'{theme.number}{node!r}{theme.reset}', True + return f'{t.number}{node!r}{t.reset}', True if isinstance(node, (str, bytes)): - return f'{theme.string}{node!r}{theme.reset}', True + return f'{t.string}{node!r}{t.reset}', True return repr(node), True if not isinstance(node, AST): @@ -663,7 +663,7 @@ def main(args=None): import argparse import sys - parser = argparse.ArgumentParser(color=True) + parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('infile', nargs='?', default='-', help='the file to parse; defaults to stdin') parser.add_argument('-m', '--mode', default='exec', @@ -682,7 +682,7 @@ def main(args=None): '(for example, 3.10)') parser.add_argument('-O', '--optimize', type=int, default=-1, metavar='LEVEL', - help='optimization level for parser (default -1)') + help='optimization level for parser') parser.add_argument('--show-empty', default=False, action='store_true', help='show empty lists and fields in dump output') args = parser.parse_args(args)