Python AST 与保留注释

31

我可以使用以下方法获得没有注释的AST:

import ast
module = ast.parse(open('/path/to/module.py').read())

你能展示一个获取保留注释(和空格)的 AST 的例子吗?


为什么不直接导入模块呢? - JBernardo
那也会去掉注释。就像大多数解析器一样 - 除非你正在进行源到源的转换并且期望输入是由人类编写的且输出可读性强,否则保留它们没有任何价值。这很少见,而且实现起来相当麻烦,所以很少这样做。 - user395760
7
我不想导入模块。它可能会引发异常,因此我只想分析它。注释可能包含一些有价值的信息,因此我希望以某种方式获取它们。我可以使用AST中的行号扫描源代码,但希望有更好的方法。在另一个问题中,一些人建议查看lib2to3 - Andrei
我之前不知道这个区别,但如果你正在阅读这个问题,你可能是在寻找一个具体语法树(concrete syntax tree,CST)而不是一个抽象语法树(abstract syntax tree,AST)Lai Jimmy 在 Stack Overflow 的回答解释了这一点,并展示了 Instagram 是如何构建这样一个模块的,通过使用 import libcst as cstcst.parse_module(some_python_filecontent),可以得到带有注释的 CST。 - a.t.
8个回答

20

ast 模块不包含注释。而tokenize 模块可以提供注释,但不提供其他程序结构。


2
抽象语法树不包含它们是有原因的。我也遇到了一些多行字符串的问题,因为ast.Strcol_offset=-1,而lineno是字符串的最后一行。所有这些问题都可以通过同时使用asttokenize来解决。谢谢。 - Andrei
嘿,你能举个例子展示如何使用ast和token解析“#hi”吗? - Siddhant Shrivastav

16

保留格式、注释等信息的AST称为完全语法树。

redbaron可以实现此功能。使用pip install redbaron进行安装,然后尝试以下代码。

import redbaron

with open("/path/to/module.py", "r") as source_code:
    red = redbaron.RedBaron(source_code.read())

print (red.fst())

1
Redbaron 在多个方面都非常出色,但不幸的是它目前不支持解析 Python3。 - juanjux
6
不幸的是,“redbaron”和它所基于的“baron”库存在严重问题,至少“redbaron”似乎已经很长时间没有得到维护。我努力让它执行基本转换,但在修复的过程中发现测试不足。我的PR已经等了4个月仍未得到回应。在解决这个问题后,我又遇到了底层“baron”库的基本源代码解析问题。此时,我找到了“lib2to3”,从此没有再回头。 - chadrik

10

在编写任何Python代码美化器、PEP-8检查器等时,这个问题自然而然地出现了。在这种情况下,您正在进行源到源的转换,您希望输入由人类编写,并且不仅希望输出可读性强,而且还期望它:

  1. 包括所有注释,恰好与原始内容中出现的位置相同。
  2. 输出字符串的正确拼写,包括原始中的文档字符串。

使用ast模块实现这一点绝非易事。您可以将其视为API中的漏洞,但似乎没有简单的方法来扩展API以轻松完成1和2。

安德烈的建议是同时使用ast和tokenize,这是一个聪明的解决方法。当我编写Python到Coffeescript转换器时,我也想到了这个思路,但这段代码远非简单。

TokenSync (ts)类从py2cs.py第1305行开始,协调基于标记的数据和ast遍历之间的通信。给定源字符串s,TokenSync类标记化s并初始化内部数据结构,支持几个接口方法:

ts.leading_lines(node):返回前面的注释和空行列表。

ts.trailing_comment(node):如果有,则返回包含节点尾部注释的字符串。

ts.sync_string(node):返回给定节点处字符串的拼写。

对于ast访问者来说,使用这些方法是很简单的,但有点笨拙。以下是py2cs.py中CoffeeScriptTraverser (cst)类的一些示例:

def do_Str(self, node):
    '''A string constant, including docstrings.'''
    if hasattr(node, 'lineno'):
        return self.sync_string(node)

只要以它们在源代码中出现的顺序访问ast.Str节点,就可以正常工作。在大多数遍历中,这种情况会自然发生。

这里是ast.If访问器的例子。它展示了如何使用ts.leading_linests.trailing_comment

def do_If(self, node):

    result = self.leading_lines(node)
    tail = self.trailing_comment(node)
    s = 'if %s:%s' % (self.visit(node.test), tail)
    result.append(self.indent(s))
    for z in node.body:
        self.level += 1
        result.append(self.visit(z))
        self.level -= 1
    if node.orelse:
        tail = self.tail_after_body(node.body, node.orelse, result)
        result.append(self.indent('else:' + tail))
        for z in node.orelse:
            self.level += 1
            result.append(self.visit(z))
            self.level -= 1
    return ''.join(result)

ts.tail_after_body 方法的作用是弥补没有代表 'else' 子句的 AST 节点这一事实。它并不是特别复杂,但也不太美观:

def tail_after_body(self, body, aList, result):
    '''
    Return the tail of the 'else' or 'finally' statement following the given body.
    aList is the node.orelse or node.finalbody list.
    '''
    node = self.last_node(body)
    if node:
        max_n = node.lineno
        leading = self.leading_lines(aList[0])
        if leading:
            result.extend(leading)
            max_n += len(leading)
        tail = self.trailing_comment_at_lineno(max_n + 1)
    else:
        tail = '\n'
    return tail
请注意,cst.tail_after_body只是调用了ts.tail_after_body
TokenSync类封装了让基于令牌的数据对ast遍历代码可用的大部分复杂性。使用TokenSync类很简单,但所有Python语句(以及ast.Str)的ast访问者必须包括对ts.leading_linests.trailing_commentts.sync_string的调用。此外,需要使用ts.tail_after_body来处理“缺失”的ast节点。
简而言之,代码工作得很好,但有点冗长。
@Andrei:你的简短答案可能暗示着你知道一种更优雅的方式。如果是这样,我很想看到它。
Edward K. Ream

8
几个人已经提到了lib2to3,但我想创建一个更完整的答案,因为这个工具是一个被低估的宝石。不要费心使用redbaronlib2to3由几个部分组成:
  • 解析器:标记、语法等
  • 修复程序:转换库
  • 重构工具:将修复程序应用于解析后的AST
  • 命令行:选择修复程序并使用多进程并行运行它们
下面简要介绍如何使用lib2to3进行转换和抓取数据(即提取)。

转换

如果您想要转换Python文件(即复杂的查找/替换),则lib2to3提供的CLI功能齐全,并且可以并行转换文件。

要使用它,请创建一个Python包,其中每个子模块都包含lib2to3.fixer_base.BaseFix的单个子类。请参见lib2to3.fixes以获取许多示例。

然后创建您的可执行脚本(将“myfixes”替换为您的包名称):

import sys
import lib2to3.main

def main(args=None):
    sys.exit(lib2to3.main.main("myfixes", args=args))

if __name__ == '__main__':
    main()

运行yourscript -h以查看选项。

网络爬虫

如果您的目标是收集数据而不是转换数据,那么您需要做更多的工作。这里是我为使用lib2to3进行数据抓取编写的配方:

# file: basescraper.py
from __future__ import absolute_import, print_function

from lib2to3.pgen2 import token
from lib2to3.pgen2.parse import ParseError
from lib2to3.pygram import python_grammar
from lib2to3.refactor import RefactoringTool
from lib2to3 import fixer_base


def symbol_name(number):
    """
    Get a human-friendly name from a token or symbol

    Very handy for debugging.
    """
    try:
        return token.tok_name[number]
    except KeyError:
        return python_grammar.number2symbol[number]


class SimpleRefactoringTool(RefactoringTool):
    def __init__(self, scraper_classes, options=None, explicit=None):
        self.fixers = None
        self.scraper_classes = scraper_classes
        # first argument is a list of fixer paths, as strings. we override
        # get_fixers, so we don't need it.
        super(SimpleRefactoringTool, self).__init__(None, options, explicit)

    def get_fixers(self):
        """
        Override base method to get fixers from passed fixers classes instead
        of via dotted-module-paths.
        """
        self.fixers = [cls(self.options, self.fixer_log)
                       for cls in self.scraper_classes]
        return (self.fixers, [])

    def get_results(self):
        """
        Get the scraped results returned from `scraper_classes`
        """
        return {type(fixer): fixer.results for fixer in self.fixers}


class BaseScraper(fixer_base.BaseFix):
    """
    Base class for a fixer that stores results.

    lib2to3 was designed with transformation in mind, but if you just want
    to scrape results, you need a way to pass data back to the caller.
    """
    BM_compatible = True

    def __init__(self, options, log):
        self.results = []
        super(BaseScraper, self).__init__(options, log)

    def scrape(self, node, match):
        raise NotImplementedError

    def transform(self, node, match):
        result = self.scrape(node, match)
        if result is not None:
            self.results.append(result)


def scrape(code, scraper):
    """
    Simple interface when you have a single scraper class.
    """
    tool = SimpleRefactoringTool([scraper])
    tool.refactor_string(code, '<test.py>')
    return tool.get_results()[scraper]

下面是一个简单的爬虫程序,它可以找到函数定义后的第一条评论:

# file: commentscraper.py
from basescraper import scrape, BaseScraper, ParseError

class FindComments(BaseScraper):

    PATTERN = """ 
    funcdef< 'def' name=any parameters< '(' [any] ')' >
           ['->' any] ':' suite=any+ >
    """

    def scrape(self, node, results):
        suite = results["suite"]
        name = results["name"]

        if suite[0].children[1].type == token.INDENT:
            indent_node = suite[0].children[1]
            return (str(name), indent_node.prefix.strip())
        else:
            # e.g. "def foo(...): x = 5; y = 7"
            # nothing to save
            return

# example usage:

code = '''\

@decorator
def foobar():
    # type: comment goes here
    """
    docstring
    """
    pass

'''
comments = scrape(code, FindTypeComments)
assert comments == [('foobar', '# type: comment goes here')]

谢谢。将这个模块命名为lib2to3实在是低估了它的价值。 - blhsing
问题1:PATTERN变量的目的是什么?是否有文档记录其中使用的语法? - Nils Lindemann
问题2:它是否检测空格,还是将其丢弃?例如,我能否找到foo<whitespace>()的出现,但不能找到foo()的出现? - Nils Lindemann
1
PATTERNfixer_base.BaseFix的抽象属性。我从未找到过任何关于它的文档,但在修复模块中有很多示例。如果需要更多示例,可以查看future库中的修复。关键字对应于Python语法中的符号。PATTERN使用的树匹配语言的语法在这里 - chadrik
使用lib2to3作为基础的bowler文档提供了更详细的解释:https://pybowler.io/docs/api-selectors - chadrik
很遗憾:自3.11版本起已被弃用,将在3.13版本中移除。https://docs.python.org/3/library/2to3.html#module-lib2to3 - fritzo

8

LibCST为Python提供了一个看起来和感觉像AST的具体语法树。大多数节点类型与AST相同,但格式化信息(注释、空格、逗号等)可用。 https://github.com/Instagram/LibCST/

In [1]: import libcst as cst

In [2]: cst.parse_statement("fn(1, 2)  # a comment")                                                                                                                
Out[2]:
SimpleStatementLine(
    body=[
        Expr(
            value=Call(
                func=Name(
                    value='fn',
                    lpar=[],
                    rpar=[],
                ),
                args=[
                    Arg(
                        value=Integer(
                            value='1',
                            lpar=[],
                            rpar=[],
                        ),
                        keyword=None,
                        equal=MaybeSentinel.DEFAULT,
                        comma=Comma(        # <--- a comma
                            whitespace_before=SimpleWhitespace(
                                value='',
                            ),
                            whitespace_after=SimpleWhitespace(
                                value=' ',  # <--- a white space
                            ),
                        ),
                        star='',
                        whitespace_after_star=SimpleWhitespace(
                            value='',
                        ),
                        whitespace_after_arg=SimpleWhitespace(
                            value='',
                        ),
                    ),
                    Arg(
                        value=Integer(
                            value='2',
                            lpar=[],
                            rpar=[],
                        ),
                        keyword=None,
                        equal=MaybeSentinel.DEFAULT,
                        comma=MaybeSentinel.DEFAULT,
                        star='',
                        whitespace_after_star=SimpleWhitespace(
                            value='',
                        ),
                        whitespace_after_arg=SimpleWhitespace(
                            value='',
                        ),
                    ),
                ],
                lpar=[],
                rpar=[],
                whitespace_after_func=SimpleWhitespace(
                    value='',
                ),
                whitespace_before_args=SimpleWhitespace(
                    value='',
                ),
            ),
            semicolon=MaybeSentinel.DEFAULT,
        ),
    ],
    leading_lines=[],
    trailing_whitespace=TrailingWhitespace(
        whitespace=SimpleWhitespace(
            value='  ',
        ),
        comment=Comment(
            value='# a comment',  # <--- comment
        ),
        newline=Newline(
            value=None,
        ),
    ),
)

6
如果您正在使用Python 3,可以使用基于lib2to3的bowler。它提供了一个更好的API和CLI来创建转换脚本。

https://pybowler.io/


1
您可以使用 ast-comments 来处理特定情况(https://pypi.org/project/ast-comments/)。
该库使用标准库中的 asttokenize,如在https://dev59.com/l2s05IYBdhLWcg3wFOC8#7457047中所述。
>>> import ast_comments as astcom
>>> source = """
... # some comments (1)
... some_variable = 'value' # inline comments (2)
... """
>>> tree = astcom.parse(source)
>>> node = tree.body[0]
>>> node.comments
('some comments (1)', 'inline comments (2)')
>>> astcom.dump(tree)
"Module(body=[Assign(targets=[Name(id='some_variable', ctx=Store())], value=Constant(value='value', kind=None), type_comment=None, comments=('some comments (1)', 'inline comments (2)'))], type_ignores=[])"

0

其他专家似乎认为Python AST模块会剥离注释,这意味着该方法对您来说根本行不通。

我们的DMS软件重构工具包及其Python前端将解析Python并构建AST,以捕获所有注释(请参阅此SO示例)。 Python前端包括一个漂亮的打印机,可以直接从AST中重新生成Python代码(包括注释!)。 DMS本身提供了低级解析机制和源到源转换功能,可使用目标语言(例如Python)表面语法编写的模式进行操作。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接