我在Datadog的工作让我一直忙于应对新的和有问题的挑战。最近我偶然发现了一个问题,听起来很简单,但比我想象的要难。
问题是这样的:假设有一个文件名和一个行号,您能说出这行代码属于哪个函数、方法或类吗?
我开始深入研究标准库,但没有找到解决这个问题的任何东西。听起来好像我必须自己编写一些东西来解决这个问题。
第一步听起来很容易。打开一个文件,读取它,找到行号。就是这样。
那么,您如何知道这一行是在哪个函数中呢?您是无法预期的,除非您解析整个文件并跟踪函数定义。那使用一个正则表达式来解析每一行可能是一个解决方案呢?
您必须小心,因为函数定义可以跨越多行。
使用AST
我认为一个好的健壮的策略不是使用手动解析或类似的方法,而是直接使用Python抽象语法树(AST)。通过利用Python自己的解析代码,我确信在解析Python源文件时不会失败。
这可以通过以下代码来简单地实现:
这样您就完成了。是吗?并不是,因为这只适用于99.99%的情况。如果您的源文件使用的编码现在是ASCII或UTF-8,则该函数会失败。我知道您认为我疯了,但我希望我的代码是健壮的。
原来Python有一个cookie来以# encoding: utf-8的形式指定编码方式,正如PEP 263中定义的那样。阅读这个cookie将有助于找到其编码方式。
要做到这一点,我们需要以二进制模式打开文件,使用一个正则表达式来匹配数据,并且……嗯,它很乏味,而且已经有人为我们实现了它,所以让我们来使用Python所提供的神奇的tokenize.open函数:
这应该在100%的时间内都有效。直到被证明并非如此。
浏览AST
parse_file函数现在会返回一个Python AST。如果您从未使用过Python AST,那么它是一个巨大的树,表示您的源代码在被编译成Python字节码之前的样子。
在这个树中,应该有语句和表达式。在本例中,我们所感兴趣的是找到与我们的行号最接近的函数定义。下面是该函数的实现:
这个函数会遍历AST的所有节点,并返回行号与我们的定义最接近的节点。如果我们有一个文件包含以下代码:
下面是以第一行到第五行为参数,调用函数filename_and_lineno_to_def得到的5个结果:
以第一行到第五行为参数,调用函数filename_and_lineno_to_def
很有用!
闭包?
前面描述的简单方法可能适用于90%的代码,但也有一些边缘情况。例如,当定义函数闭包时,上面的算法就不行了。当有如下代码时:
以第1行到第7行为参数,调用函数filename_and_lineno_to_def:
返回第1行到第7行的filename_and_lineo_to_def的结果
哎呀。显然,第6行和第7行不属于foo函数。我们的方法太简单了,以至于从第6行开始,我们就回到了y方法。
区间树
正确处理上述问题的方法是将每个函数定义视为一个区间:
可以视为区间的代码片段
无论我们请求的行号是什么,我们都应该返回负责该行所在的最小间隔的节点。
在这种情况下,我们需要一个正确的数据结构来解决我们的问题:一个区间树正好非常适合我们的情况。它允许我们快速搜索与我们的行号相匹配的代码片段。
要解决我们的问题,我们需要以下几个东西:
一种计算函数的起始行号和结束行号的方法。
一个用我们之前计算的间隔来填充的树。
当一个行是多个函数(闭包)的一部分时,选择最佳匹配区间的方法。
计算函数区间
一个函数的区间是组成该函数主体的第一行和最后一行。通过遍历函数AST节点很容易找到这些行:
对于任意AST节点,该函数会返回一个此节点的第一个和最后一个行号的元组。
构建区间树
我们将使用intervaltree库而不是自己实现一个区间树。我们需要创建一个树,并用计算出的区间来填充它:
就是这样:该函数会解析作为参数传入的Python文件,并将其转换为它的AST表示。然后遍历它并为区间树提供每个类和函数定义。
查询区间树
现在已经构建了区间树,我们就可以使用行号来对它进行查询了。这很简单:
如果有多个区间包含我们的行号,则该构建树可能会返回几个匹配项。在这种情况下,我们选择最小的区间并返回此节点的名称——也就是我们的类名或函数名!
任务完成
我们做到了!我们从一个简单的方法开始,并迭代到一个最终的解决方案,它覆盖了我们100%的情况。选择正确的数据结构,这里是区间树,帮助我们用一个智能的方法解决了这个问题。
>>> 今日的签到口令:z23p <<<
英文原文:https://julien.danjou.info/finding-definitions-from-a-source-file-and-a-line-number-in-python/
译者:Nothing