Ruff v0.4.0 现已发布!您可以从 PyPI 或您选择的包管理器安装它

pip install --upgrade ruff

Ruff 是一个用 Rust 编写的超快速 Python 代码检查器和格式化工具。Ruff 可以替代 Black、Flake8(以及数十个插件)、isort、pydocstyle、pyupgrade 等工具,其执行速度比任何单个工具快数十倍乃至数百倍。


此次发布标志着 Ruff 开发的一个重要里程碑,我们从一个生成式解析器转向了手写递归下降解析器。

Ruff 的新解析器速度提升了2倍以上,这意味着所有代码检查和格式化操作都将获得 20-40% 的速度提升

仓库代码检查器 (v0.3)代码检查器 (v0.4)格式化工具 (v0.3)格式化工具 (v0.4)
home-assistant/core449.9364.1381.9307.8
pytorch/pytorch328.7251.8351.1274.9
python/cpython134.694.4180.2138.3
huggingface/transformers198.5143.6239.0184.1

对流行仓库进行代码检查和格式化所需的时间(毫秒)。数值越低越好。

手写解析器也为未来的优化和改进打开了大门,尤其是在错误恢复方面。

请继续阅读主要变更的讨论,或查看更新日志

手写解析器

解析器是任何静态分析工具的基础层,它将原始源代码转换为抽象语法树(AST),而抽象语法树是进行分析的基础。

Ruff v0.4.0 引入了一个手写递归下降解析器,取代了现有的生成式解析器。

两者之间的区别在于它们的实现方式

  • 生成式解析器是使用一种名为解析器生成器(在我们的案例中是LALRPOP)的工具创建的。通常,解析器生成器要求语法在领域特定语言(DSL)中定义,然后由生成器将其转换为可执行代码。在我们的案例中,规则定义在 .lalrpop 文件中,LALRPOP 将其转换为 Rust 代码。

  • 另一方面,手写解析器则涉及将解析规则直接编码在 Rust 代码中,使用函数来定义各个节点的解析逻辑。

Ruff 最初发布时使用了来自 RustPython 项目的 Python 解析器。随着 Ruff 的发展,我们发现 Python 解释器和代码检查器有不同的需求,这两种用例的理想 AST 可能看起来大相径庭。最终,我们将解析器整合到 Ruff 中,并随着 AST 结构的发展对其进行独立维护。

正是因为这些,Ruff 项目的贡献者 Victor Hugo Gomes 提出了一个拉取请求,引入了手写递归下降解析器。这是一个雄心勃勃的提案,但考虑到以下几点,它对 Ruff 的未来非常有意义:

  1. 我们已经独立于 RustPython 维护解析器。
  2. 我们对所需的 AST 结构有了清晰的理解。
  3. Victor 已经证明手写解析器的性能将显著优于生成式解析器。
  4. 讽刺的是,生成式解析器反而变得更难维护。解析器生成器对它们能支持的语法有限制,我们已经发现自己需要与 LALRPOP 斗争才能支持最新的 Python 语法。
  5. 手写解析器将使我们对错误处理和恢复有更大的控制权,这对于构建对语法错误具有弹性的编辑器友好工具尤为重要。

从那时起,我们与 Victor 紧密合作,将解析器整合到 Ruff 中,并添加了对最新 Python 语法的支持。一旦解析器完全符合 Ruff 自己的测试套件,我们又花了几个月的时间,在数百万行实际 Python 代码和模糊测试生成输入上测试和验证其准确性和可靠性。

优势

与我们最初的动机一致,引入递归下降解析器带来了相对于生成式解析器的多项优势。

控制与灵活性

手写解析器对解析过程拥有完全控制权,这使得在处理边缘情况时具有更大的灵活性。例如,Python 中带括号的 with 语句项引入了关于开括号“属于”哪个节点的语法歧义

# Parenthesis belongs to the `with` item
with (item): ...
#    ^^^^^^ with item

# Parenthesis belongs to the context expression which is part of the `with` item
with (item) as var: ...
#    ^^^^^^        context expression
#    ^^^^^^^^^^^^^ with item

在生成式解析器中编码这种歧义可能具有挑战性,而手写解析器则为您提供了处理此类情况所需的灵活性。

性能

手写解析器明显更快。优化解析器生成器很困难,因为我们对生成代码的控制极少,也很少有机会利用关于热路径和冷路径以及其他数据属性的领域特定知识。虽然我们可以优化手写词法分析器,但解析器仍然是一个黑盒子。

基准测试LALRPOP 解析器手写解析器变化
parser[large/dataset.py]63.6 毫秒26.6 毫秒×2.4
parser[numpy/ctypeslib.py]10.8 毫秒5 毫秒×2.2
parser[numpy/globals.py]964.6 微秒424.9 微秒×2.3
parser[pydantic/types.py]24.4 毫秒10.9 毫秒×2.2
parser[unicode/pypinyin.py]3.8 毫秒1.7 毫秒×2.2

两种解析器之间的微基准比较。

Ruff 的手写解析器比生成式解析器速度提升了2倍以上,这意味着所有代码检查和格式化操作都将获得 20-40% 的速度提升。

错误处理

借助手写解析器,我们现在可以在遇到语法错误时提供更好的错误消息,如下例所示

--- a/ruff/parser/old_error_messages
+++ b/ruff/parser/new_error_messages
   |
 1 | from x import
-  |              ^ SyntaxError: Unexpected token Newline
+  |              ^ SyntaxError: Expected one or more symbol names after import

   |
 1 | async while test: ...
-  |       ^ SyntaxError: Unexpected token 'while'
+  |       ^ SyntaxError: Expected 'def', 'with' or 'for' to follow 'async', found 'while'
   |

   |
 1 | a; if b: pass; b
-  |    ^ SyntaxError: Unexpected token 'if'
+  |    ^ SyntaxError: Compound statements are not allowed on the same line as simple statements
   |

   |
 1 | with (item1, item2), item3,: ...
-  |                            ^ SyntaxError: Unexpected token ':'
+  |                           ^ SyntaxError: Trailing comma not allowed
   |

   |
 1 | x = *a and b
-  |        ^ SyntaxError: Unexpected token 'and'
+  |      ^ SyntaxError: Boolean expression cannot be used here
   |

错误弹性

对于我们的许多用户来说,Ruff 是一个集成在编辑器中的工具,而在编辑器中,即使是暂时性地出现语法错误也很常见。例如,想象一下您正在定义一个新函数。您已经输入了 def func (x):,但尚未填写函数体。虽然您的代码在语法上无效,您仍然希望看到文件中其余部分的检查和格式化诊断信息。

手写解析器使我们能够支持错误恢复,从而在 Ruff 中构建错误弹性。这意味着 Ruff 的解析器可以从源代码中的语法错误中恢复,并尽管中断也能继续解析。

这在编辑器中会是什么样子?想象一下您在代码中犯了多个语法错误,如下所示

import os  # unused-import (F401)


def fibonacci(n):
    """Compute the nth number in the Fibonacci sequence."""
    x = 1  # unused-variable (F841)
    if n in (0, 1)
        #         ^ SyntaxError: Expected ':', found newline
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)


if __name__ == "__main__":
    import sys

    1 = int(sys.argv[1])
#   ^ SyntaxError: Invalid assignment target
    print(fibonacci(n))  # undefined-name (F821)

有了错误弹性解析器,Ruff 即使在遇到上述语法错误后也能继续分析代码,这使得代码检查器能够在一个运行中提供甚至修复诊断信息。

虽然 Ruff 尚未展现这种错误弹性行为,但手写解析器为此奠定了基础,我们计划在未来的版本中实现它。

下一步是什么

展望未来,我们旨在通过以下目标进一步提升 Ruff 的解析能力

  • 完整的错误恢复:确保解析器从所有语法错误中恢复,为开发者提供不间断的编辑器体验。
  • 报告所有语法错误:显示解析过程中遇到的所有语法错误,为开发者提供代码中问题的完整概览。
  • 持续分析:即使存在语法错误,也允许代码检查器继续进行分析。

最终,我们的目标是通过使 Ruff 更快,并且关键的是对语法错误具有弹性,从而实现一流的编辑器体验。

感谢!

最后,我们要感谢 RustPython 项目,感谢他们使我们能够利用他们的 Python 解析器。RustPython 解析器是 Ruff 早期开发的重要推动者,我们感谢有机会与他们合作并在他们的工作基础上进行构建。

我们还要感谢 Victor Hugo Gomes,是他发起了向手写解析器过渡,并为此付出了所有努力使其符合 Ruff 的要求;还要感谢 Addison Crump,他贡献了我们用来验证新解析器的模糊测试工具。


GitHub 上查看完整的更新日志。

阅读更多关于 Astral 的信息——Ruff 背后的公司。