(即将成为)我能想到的最快的纯Python PEG解析器
项目描述
Parsimonious旨在成为最快的纯Python任意前瞻解析器——并且是最易用的。它基于解析表达式文法(PEGs),这意味着你给它提供一种简化的EBNF符号表示。Parsimonious被设计来支持一个MediaWiki解析器,该解析器不会花费5秒钟或1GB的RAM来处理一页,但它适用于各种语言。
- 代码::
- 问题:
- 许可证:
MIT 许可证 (MIT)
- 包:
目标
速度
节省 RAM 使用
简洁、易于理解、符合 Python 习惯的代码
可读的语法
可扩展的语法
完整的测试覆盖
关注点的分离。一些 Python 解析器将识别与如何将结果树转换为某种其他表示形式的指令混合在一起。当你想对树进行多种操作时,这会有限制:例如,将维基标记渲染为 HTML 或 文本。
良好的错误报告。我希望解析器在开发语法时能与我合作。
安装
要安装 Parsimonious,请运行
$ pip install parsimonious
示例用法
这是构建简单语法的示例
>>> from parsimonious.grammar import Grammar
>>> grammar = Grammar(
... """
... bold_text = bold_open text bold_close
... text = ~"[A-Z 0-9]*"i
... bold_open = "(("
... bold_close = "))"
... """)
你可以有前向引用,甚至右侧递归;语法编译器都会处理。第一条规则被认为是默认的起始符号,但你也可以覆盖它。
接下来,让我们解析一些内容并获得抽象语法树
>>> print(grammar.parse('((bold stuff))'))
<Node called "bold_text" matching "((bold stuff))">
<Node called "bold_open" matching "((">
<RegexNode called "text" matching "bold stuff">
<Node called "bold_close" matching "))">
通常,你会使用 nodes.NodeVisitor 子类(见下文)来遍历树并对其进行有用的操作。
另一个示例是实现 .ini 文件的解析器。考虑以下内容
grammar = Grammar(
r"""
expr = (entry / emptyline)*
entry = section pair*
section = lpar word rpar ws
pair = key equal value ws?
key = word+
value = (word / quoted)+
word = ~r"[-\w]+"
quoted = ~'"[^\"]+"'
equal = ws? "=" ws?
lpar = "["
rpar = "]"
ws = ~"\s*"
emptyline = ws+
"""
)
我们可以这样实现一个 NodeVisitor 的子类
class IniVisitor(NodeVisitor):
def visit_expr(self, node, visited_children):
""" Returns the overall output. """
output = {}
for child in visited_children:
output.update(child[0])
return output
def visit_entry(self, node, visited_children):
""" Makes a dict of the section (as key) and the key/value pairs. """
key, values = visited_children
return {key: dict(values)}
def visit_section(self, node, visited_children):
""" Gets the section name. """
_, section, *_ = visited_children
return section.text
def visit_pair(self, node, visited_children):
""" Gets each key/value pair, returns a tuple. """
key, _, value, *_ = node.children
return key.text, value.text
def generic_visit(self, node, visited_children):
""" The generic visit method. """
return visited_children or node
并这样调用它
from parsimonious.grammar import Grammar
from parsimonious.nodes import NodeVisitor
data = """[section]
somekey = somevalue
someotherkey=someothervalue
[anothersection]
key123 = "what the heck?"
key456="yet another one here"
"""
tree = grammar.parse(data)
iv = IniVisitor()
output = iv.visit(tree)
print(output)
这将产生
{'section': {'somekey': 'somevalue', 'someotherkey': 'someothervalue'}, 'anothersection': {'key123': '"what the heck?"', 'key456': '"yet another one here"'}}
状态
所有存在的东西都正常工作。测试覆盖率良好。
我计划在将来不会对规则语法进行任何不兼容的更改,因此你可以有信心地编写语法。
它可能运行得慢,并且会使用大量 RAM;我还没有测量过。然而,我还没有真正开始优化。
错误报告现在已经到位。表达式、语法和节点的 repr 方法清晰且有用。语法那些甚至可以往返!
目前语法可扩展性故事发展不足。你应该能够通过简单地将更多规则附加到现有规则上扩展语法;同名的后续规则应覆盖先前的规则。然而,这尚未经过测试,可能不是最终的故事。
Sphinx 文档即将到来,但现在文档字符串非常有用。
请注意,直到我们达到 1.0,可能会有 API 变更,因此请确保固定到你正在使用的版本。
即将推出
优化以使 Parsimonious 当之无愧
更紧密的 RAM 使用
更好的语法可扩展性故事
惊人的语法调试
关于 PEG 解析器的一些信息
PEG 解析器不区分词法分析和解析;一切都是在一次完成的。因此,没有预览限制,比如 Yacc 中的那样。由于这两个特性,PEG 语法更容易编写:它们基本上是 EBNF 的一个更实用的方言。有了缓存,它们占用 O(语法大小 * 文本长度) 的内存(尽管我计划做得更好),但它们的运行时间在 O(文本长度)。
更技术性的描述
PEGs可以描述LL(k)语言的超集,任何确定性的LR(k)语言,以及其他许多语言——包括一些非上下文无关的语言(见http://www.brynosaurus.com/pub/lang/peg.pdf)。它们还可以处理在规范EBNF中描述时会变得模糊不清的语言。它们通过将|选择运算符替换为/运算符来实现这一点,后者与前者作用相同,但明确指出优先级:a / b / c首先尝试匹配a。如果失败,则尝试b,如果失败,则继续尝试c。因此,通过始终返回第一个成功的识别来消除歧义。
编写语法
语法通过一系列规则定义。语法应该对使用正则表达式或阅读编程语言手册的人来说是熟悉的。一个例子将最好地说明这一点
my_grammar = Grammar(r"""
styled_text = bold_text / italic_text
bold_text = "((" text "))"
italic_text = "''" text "''"
text = ~"[A-Z 0-9]*"i
""")
如果您喜欢,可以将规则跨多行包装;语法非常宽容。
语法参考
"some literal" |
用于引用文本。反斜杠转义和Python的“原始”和Unicode字符串约定有助于支持复杂字符。 |
b"some literal" |
字节文本。使用字节文本和正则表达式允许您的语法解析二进制文件。请注意,语法中的所有文本和正则表达式必须具有相同的类型。在处理字节的语法中,您应将语法字符串定义为r"""string""",这样字节文本如\xff就可以正确工作了。 |
[space] |
序列由空格或制表符分隔的项组成。 a b c匹配这些三个项按顺序出现的位置。 |
a / b / c |
选择。在a / b / c中,第一个成功匹配的项获胜。 |
thing? |
一个可选的表达式。这是一个贪婪表达式,如果存在,则始终消耗thing。 |
&thing |
一个前瞻断言。确保thing在当前位置匹配,但不消耗它。 |
!thing |
一个负前瞻断言。如果此处未找到thing,则匹配。不会消耗任何文本。 |
things* |
零个或多个things。这是一个贪婪表达式,始终消耗尽可能多的重复项。 |
things+ |
一个或多个things。这是一个贪婪表达式,始终消耗尽可能多的重复项。 |
~r"regex"ilmsuxa |
正则表达式前面有~,并且像文本一样引用。任何标志(作为ilmx)在引号后作为单个字符跟随。正则表达式非常适合表示字符类(如[a-z0-9])并优化速度。缺点是,一旦我们使我们的调试变得复杂,它们将无法利用我们的高级调试功能。最终,我希望弃用显式正则表达式,并让Parsimonious动态构建它们。Parsimonious使用regex库而不是内置的re模块。 |
~br"regex" |
字节正则表达式;如果您的语法解析字节字符串,则需要。 |
(things) |
括号用于分组,就像在所有其他语言中一样。 |
thing{n} |
恰好n次重复的thing。 |
thing{n,m} |
介于n和m之间(含)的重复次数。 |
thing{,m} |
最多m次重复的thing。 |
thing{n,} |
至少n次重复的thing。 |
优化语法
不要重复表达式
如果在你的语法中有两个位置需要使用 ~"[a-z0-9]"i,请不要重复输入。将其作为一条规则,并在需要的地方引用它。这样,你将充分利用缓存,因为缓存查找是通过表达式对象身份(为了速度)进行的。
即使你有一个非常简单的表达式,不重复它也能节省RAM,因为最坏的情况下,你解析的文本中的每个字符都可能有一个缓存的整数值。在将来,我们可能在构建语法时自动识别重复的子表达式并将它们提取出来。
将多少内容放入一个正则表达式,与将它们拆分成多少以避免重复?这是一个微妙的平衡,值得基准测试。更多的内容放入正则表达式将执行得更快,因为它不需要在各个部分之间运行Python,但拆分后的一个将提供更好的缓存性能,如果单个部分在其他地方被重用。如果正则表达式的部分在其他地方没有被使用,那么当然可以保持整个表达式在一起。
量词
将你的 ? 和 * 量词提升到最高级别。否则,较低级别的模式可能会成功,但却是空的,并在你的树中放置很多无用的节点,这些节点实际上并没有匹配任何内容。
处理解析树
解析树对每个匹配的表达式都有一个节点,即使它匹配了一个零长度的字符串,比如 "thing"? 可能会匹配。
NodeVisitor 类提供了一个控制反转框架,用于遍历树并返回基于它的新构造(树、字符串或任何其他内容)。现在,请查看其 docstrings 以获取更多详细信息。在 grammar.RuleVisitor 中还有一个很好的示例。注意我们如何利用节点的可迭代性,在形式参数列表中使用元组解包。
def visit_or_term(self, or_term, (slash, _, term)):
...
以下是上述解包的生产
or_term = "/" _ term
当你的访问者发生错误时,你会得到一个这样的错误
[normal traceback here...] VisitationException: 'Node' object has no attribute 'foo' Parse tree: <Node called "rules" matching "number = ~"[0-9]+""> <-- *** We were here. *** <Node matching "number = ~"[0-9]+""> <Node called "rule" matching "number = ~"[0-9]+""> <Node matching ""> <Node called "label" matching "number"> <Node matching " "> <Node called "_" matching " "> <Node matching "="> <Node matching " "> <Node called "_" matching " "> <Node called "rhs" matching "~"[0-9]+""> <Node called "term" matching "~"[0-9]+""> <Node called "atom" matching "~"[0-9]+""> <Node called "regex" matching "~"[0-9]+""> <Node matching "~"> <Node called "literal" matching ""[0-9]+""> <Node matching ""> <Node matching ""> <Node called "eol" matching " "> <Node matching "">
解析树附加到异常上,指出引发错误的节点。
为什么没有流式处理树?
有些人问我们为什么不用SAX风格逐步处理树。有两个主要原因
这是不可行的。与PEG解析器一样,直到整个文本被解析,没有解析决策是最终的。如果我们需要改变决策,我们就必须回溯并重新执行SAX风格的解释,这会涉及重构部分AST,并可能破坏你正在进行的流式输出。(注意,如果我们使用切割,未来可能可以进行一些爆发的SAX风格处理。)
它干扰了从AST推导出多个表示的能力:例如,将wiki标记转换为HTML,然后转换为文本。
未来方向
规则语法变化
也许支持像PyMeta这样的左递归规则,如果有人关心的话。
最终,我希望消除显式的正则表达式,并将它们分解成更原子的东西,如字符类。然后我们可以根据需要动态编译语法的部分到正则表达式中,以提高速度。
优化
通过自动插入“切割”,正如在http://ialab.cs.tsukuba.ac.jp/~mizusima/publications/paste513-mizushima.pdf 中所述,几乎使RAM使用保持恒定。这也会提高错误报告,因为我们不会在最终失败之前回溯出所有有用的信息。
找到所有不同的子表达式,并统一重复的以获得更好的缓存命中率。
考虑让用户(可选)提供一些具有代表性的输入和语法。然后我们可以针对它进行配置,查看哪些表达式值得缓存,并对语法进行注释。也许甚至还有位置,在那里给定的表达式更值得缓存。或者我们可以记录每个缓存条目被使用的次数,并在RAM使用增加时移除最无用的那些。
我们可以将语法编译成VM指令,就像Medeiros在“A parsing machine for PEGs”中做的那样。
如果实际中的递归太深,可以使用跳跃技术来避免。
优点
Pijnu有许多树操作器。我认为我不需要所有的,但一个合适的子集可能很好。不要将格式化与树操作混合在一起。https://github.com/erikrose/pijnu/blob/master/library/node.py#L333。PyPy的解析库公开了一个合理的子集:https://pypy-doc.pythonlang.cn/en/latest/rlib.html#tree-transformations。
版本历史
- (下一版本)
…
- 0.10.0
修复了__eq__中的无限递归问题。(FelisNivalis)
改进了左递归规则的错误信息。(lucaswiman)
添加了对范围{min,max}重复表达式的支持(righthandabacus)
修复了标记语法中*和+的bug(lucaswiman)
添加了对字节数组语法的支持(lucaswiman)
修复了LazyReference解析bug #134(righthandabacus)
基准测试中速度提高了约15%,使用了更快的节点缓存(ethframe)
- 0.9.0
添加了对Python 3.7、3.8、3.9、3.10的支持(righthandabacus、Lonnen)
停止支持Python 2.x、3.3、3.4(righthandabacus、Lonnen)
移除six,全面采用Python 3惯用语法(Lonnen)
将re替换为regex,以改进对正则表达式中的Unicode字符的处理(Oderjunkie)
移除nose以使用unittest(swayson)
Grammar.__repr__()现在正确转义反斜杠(ingolemo)
自定义规则现在可以是类方法,也可以是函数(James Addison)
在正则表达式语法中提供ascii标志(Roman Inflianskas)
- 0.8.1
在基准测试中使用函数式print,以便在Python 3上作为依赖项干净地工作。(Edward Betts)
- 0.8.0
使Grammar迭代有序,使__repr__更像原始输入。(Lucas Wiman)
改进匿名子表达式的文本表示和错误信息。(Lucas Wiman)
将BadGrammar和VisitationError公开为顶级导入。
当你尝试将节点与不同类的实例进行比较时,不再崩溃。(Esben Sonne)
将six锁定在1.9.0以确保我们有python_2_unicode_compatible。(Sam Raker)
停止支持Python 2.6。
- 0.7.0
为在预标记的标记流上操作的用户添加了实验性的基于标记的解析,通过TokenGrammar类。例如,这可以帮助解析与缩进敏感的“偏移规则”语言(如Python)的语法。(Erik Rose)
为Python 2和3提供了一个公共代码库:不再需要2to3转换步骤(Mattias Urlichs、Lucas Wiman)
停止支持Python 3.1和3.2。
修复了< span class="docutils literal">Grammar.__repr__的bug,该bug导致Python 3中无法工作,因为Python 3中的string_escape编解码器已消失。(Lucas Wiman)
打印表达式表示时不要丢失括号。(Michael Kelly)
使Grammar成为一个不可变映射(在我们添加自动重新编译之前)。(Michael Kelly)
- 0.6.2
使语法编译速度提高100倍。感谢dmoisset提供的初始补丁。
- 0.6.1
修复了当语法中包含向前引用时使默认规则无效的bug。
- 0.6
-
在Grammars中添加了对“自定义规则”的支持。这些规则提供了以Python lambda形式编写的简单自定义解析钩子。对于重负载需求,你可以将复合表达式与懒引用作为子表达式放入其中,Grammar将它们连接起来以实现最佳效率——解析时无需在Grammar上调用__getitem__。
允许没有默认规则(在没有字符串规则的情况下)的语法,这也导致了允许空语法的出现。也许有人动态构建语法时会发现这很有用。
添加了@rule装饰器,允许语法通过在NodeVisitor方法上的标记来构建。这节省了在只有一个访问者与语法相关时在访问者和语法之间的来回查找。
向NodeVisitor添加了parse()和match()便利方法。这使得解析字符串并精确应用一个访问者到AST的常见情况更短更简单。
当忘记声明访问者方法时,改进了异常信息。
向NodeVisitor添加了unwrapped_exceptions属性,允许你命名某些异常,这些异常在访问者中传播出来时不会被VisitationError异常包装。
在__init__中公开了更多库,使你的导入更短。
极大地简化了引用解析机制。(Vladimir Keleshev)
- 0.5
-
添加了alpha质量的错误报告。现在,如果parse()和match()没有成功,它们将抛出ParseError而不是返回None。这更有意义,因为您很少尝试解析某些内容而不关心它是否成功。之前的忘记检查None结果太容易了。ParseError提供了可读的Unicode表示以及一些属性,让您可以构建自己的自定义表示。
如果无法解析您的规则,Grammar构造现在将抛出ParseError而不是BadGrammar。
parse()现在接受一个可选的pos参数,就像match()一样。
使UndefinedLabel的_str__()方法返回正确的类型。
支持在多行之间拆分规则,交错注释,一行上放置多个规则(但请不要这样做)以及各种其他可怕的行为。
容忍括号后的空格。
支持单引号字面量。
- 0.4
支持Python 3。
修复了import *对parsimonious.expressions的支持。
重写了语法编译器,以便可以编译右递归规则,并且在某些情况下,不再由于向前规则引用而解析失败。
- 0.3
在语法定义语法中支持注释、!(“非”)运算符和括号。
将 & 运算符改为前缀运算符,以符合原始 PEG 语法。Parsing Techniques 中的版本是中缀,我将其作为参考。然而,一元版本更方便,因为它允许您将 AB & A 简写为 A & B。
将 print 语句从基准测试中移除。
为 Node 提供一个可评估的 __repr__。
- 0.2
通过使 match() 公共并能够初始化新缓存来支持前缀和字符串其他非末尾切片的匹配。将 match() 调用方法添加到 Grammar。
当语法定义中存在错误时,报告 BadGrammar 异常(而不是崩溃)。
简化语法编译内部机制:删除多余的访问者方法,并合并重复的方法。同时简化规则语法。
添加 NodeVisitor.lift_child 方便方法。
将 VisitationException 重命名为 VisitationError,以与标准的 Python 异常层次结构保持一致。
重新设计语法和表达式的 repr 和 str 值。现在它们看起来都像规则语法。语法甚至可以来回转换!这解决了打印解析了 Unicode 文本的节点时的 Unicode 编码错误。
添加 tox 进行测试。停止宣传 Python 2.5 支持,因为它从未工作过(除非有人非常关心,否则它会使 Python 3 支持变得困难)。
确定“规则”一词的含义为“生成本产的字符串表示”。去除模糊的“DSL”。
- 0.1
一个粗糙但可用的预览版本
感谢 Wiki Loves Monuments Panama 以慷慨的礼物表示他们的支持。
项目详情
下载文件
下载适用于您平台的文件。如果您不确定选择哪个,请了解有关 安装包 的更多信息。