大家好!
我们已经有一篇关于Ostrovok.ru中打字发展的文章 。 它解释了为什么我们要从pyContracts切换到typeguard,为什么要切换到typeguard以及最终得到什么。 今天,我将详细介绍这种过渡是如何发生的。

带有pyContracts的函数声明通常如下所示:
from contracts import new_contract import datetime @new_contract def User (x): from models import User return isinstance(x, User) @new_contract def dt_datetime (x): return isinstance(x, datetime.datetime) @contract def func(user_list, amount, dt=None): """ :type user_list: list(User) :type amount: int|float :type dt: dt_datetime|None :rtype: bool """ …
这是一个抽象的示例,因为我没有在我们的项目中找到一个函数定义,该定义对于类型检查的案例数而言是简短而有意义的。 通常,pyContracts的定义存储在不包含任何其他逻辑的文件中。 请注意,这里的User是特定的用户类,并且不会直接导入。
这是使用typeguard的理想结果:
from typechecked import typechecked from typing import List, Optional, Union from models import User import datetime @typechecked def func (user_list: List[User], amount: Union[int, float], dt: Optional[datetime.datetime]=None) -> bool: ...
通常,项目中有很多类型检查功能和方法,如果将它们堆叠在堆栈中,就可以到达月球。 因此,根本不可能将它们从pyContracts转换为Typeguard(我尝试过!)。 所以我决定写一个脚本。
该脚本分为两部分:第一部分缓存新合同的导入,第二部分处理代码重构。
我想指出,没有一个脚本或另一个脚本声称是通用的。 我们并非旨在编写一种工具来解决所有必需的情况。 因此,我经常忽略对某些特殊情况的自动处理,如果在项目中很少发现这些特殊情况,则可以手动进行修复。 例如,用于生成映射合同和导入的脚本收集了90%的值,其余的10%是手工制作的映射。
脚本生成映射的逻辑:
步骤1.浏览项目的所有文件,阅读它们。 对于每个文件:
- 如果子字符串“ @new_contract”不存在,请跳过此文件,
- 如果存在,则通过“ @new_contract”行分割文件。 对于每个项目:
-解析以定义和导入,
-如果成功,则写入成功文件,
-如果不是,则写入错误文件。
步骤2.手动处理错误
现在我们有了pyContracts使用的所有类型的名称(它们是用new_contract装饰器定义的),并且我们具有所有必需的导入,我们可以编写用于重构的代码。 当我手动将pyContracts转换为typeguard时,我意识到我需要从脚本中获得什么:
- 这是一个以模块名称作为参数的命令(可以使用多个),在其中必须替换功能注释的语法。
- 浏览所有模块文件,阅读它们。 对于每个文件:
- 如果没有“ @contract”子字符串,请跳过此文件;
- 如果是这样,请将代码转换为ast(抽象语法树);
- 查找每个合同装饰器下的所有功能:
- 获取码头字符串,解析,然后删除,
- 创建{arg_name:arg_type}形式的字典,用它来替换函数注释,
- 记住新的进口,
- 通过astunparse将修改后的树写入文件;
- 在文件顶部添加新的导入;
- 用“ @typechecked”替换“ @contract”行,因为它比通过ast更容易。
解决问题“此名称是否已导入此文件?” 从一开始我就没有打算:面对这个问题,我们将应对isort库的其他运行。
但是,在运行脚本的第一个版本之后,出现了仍然必须解决的问题。 原来,1)ast不是万能的,2)astunparse比我们想要的更万能。 这体现在以下方面:
- 过渡到语法树时,所有单行注释从代码中消失;
- 空行也消失了;
- ast不区分类的函数和方法,我们不得不添加逻辑;
- 相反,当从树切换到代码时,将三引号中的多行注释写在单引号注释中并占用一行,而新的换行符由\ n代替;
- 出现不必要的括号,例如如果A和B以及C或D变成if((A和B和C)或D)。
通过ast和astunparse传递的代码仍然有效,但是降低了可读性。
上面最严重的缺点是单行注释消失了(例如,在其他情况下,我们什么也不会丢失,而只会得到-括号)。 基于ast,astunparse和tokenize的Horast库有望解决这个问题。 承诺并做到。
现在是空行。 有两种可能的解决方案:
- tokenize知道如何确定python的“语音部分”,horast在获取注释类型令牌时会利用它。 但是tokenize还具有NewLine和NL等令牌。 因此,您需要查看horast如何还原评论,并复制并替换令牌的类型。
-建议Anya,有2个月的开发经验 - 由于horast可以恢复评论,因此我们首先将所有空行替换为特定的评论,然后跳过horast并将我们的评论替换为空行。
-提出了具有8年开发经验的Eugene
我会在注释中说些三引号,然后再说一点,而且很容易加上多余的括号,尤其是因为其中一些是通过自动格式化删除的。
在horast中,我们使用两个函数:parse和unparse,但都不是理想的-parse包含奇怪的内部错误,在极少数情况下,它无法解析源代码,并且unparse无法编写具有type type(例如事实证明您是否键入(any_other_type)。
我决定不处理解析问题,因为工作的逻辑相当混乱,而且例外情况很少-非通用性原则在这里起作用。
但是unparse的工作原理非常清晰,优雅。 unparse函数创建Unparser类的实例,该类在初始化时处理树,然后将其写入文件。 Horast.Unparser继承自许多其他Unparsers,其中最基本的类是astunparse.Unparser。 所有后代类都只是扩展了基类的功能,但是工作逻辑保持不变,因此请考虑astunparse.Unparser。 它具有五个重要方法:
- 写-只写一些东西到文件中。
- fill-根据缩进数量使用写入(缩进数量存储为类字段)。
- 输入-增加缩进量。
- 离开-减少缩进。
- dispatch-确定树的节点的类型(例如T),通过节点类型的名称调用相应的方法,但下划线(即_T)。 这是一个元方法。
所有其他方法均为_T形式的方法,例如_Module或_Str。 在每种此类方法中,它可以:1)为子树节点递归调度,或2)使用write写入节点的内容或添加字符和关键字,以便结果是python中的有效表达式。
例如,我们遇到了一个arg类型的节点,其中ast存储参数名称和注释节点。 然后分派将调用_arg方法,该方法将首先写下参数名称,然后编写冒号并为注释节点运行分派,在该节点上将解析注释子树,并且仍将为此子树调用分派和写入。
让我们回到无法处理类型类型的问题。 现在您了解了unparse的工作原理,创建类型很容易。 让我们创建一些类型:
class NewType(object): def __init__ (self, t): self.s = ts
它本身存储一个字符串,而不仅仅是这样:我们需要对函数参数进行类型化处理,然后从对接中获取字符串形式的参数类型。 因此,让我们不要用所需的类型来替换参数注释,而要用一个NewType对象来替换它,该对象仅在其中存储所需类型的名称。
为此,请展开horast.Unparser-编写您的UnparserWithType,从horast.Unparser继承,并添加新类型的处理。
class UnparserWithType(horast.Unparser): def _NewType (self, t): self.write(ts)
这与图书馆的精神相结合。 变量的名称采用ast样式制作,这就是为什么它们由一个字母组成的原因,而不是因为我无法想到名称。 我认为t是树的缩写,s是字符串的缩写。 顺便说一句,NewType不是字符串。 如果我们希望将其解释为字符串类型,则必须在write调用之前和之后写引号。
现在 魔术 猴子补丁:用我们的UnparserWithType替换horast.Unparser。
现在的工作方式:我们有一个语法树,它有一些函数,函数有参数,参数有类型注释,在类型注释中隐藏了针,在其中隐藏了科什切夫的死。 以前,根本没有注释节点,我们创建了注释节点,任何这样的节点都是NewType的实例。 我们为树调用unparse函数,并且为每个节点调用unparse函数,dispatch将对该节点进行分类并调用其对应的函数。 一旦分派函数接收到参数的节点,它就会写出参数的名称,然后查看是否有注释(它以前是None,但我们把NewType放在了那里),如果有,它会写一个冒号并为该注释调用dispatch,这将调用_NewType,只写它存储的字符串-这是类型名称。 结果,我们得到了书面论点:类型。
实际上,这并不完全合法。 从编译器的角度来看,我们用一些在任何地方都未定义的单词写下了参数的注释,因此当unparse完成其工作时,我们得到了错误的代码:我们需要导入。 我可以简单地以正确格式形成一行并将其添加到文件的开头,然后将结果追加到unparse,尽管我可以将导入作为节点添加到语法树中,因为ast支持Import和ImportFrom节点。
解决三引号问题并不比添加新类型困难。 我们将创建StrType类和_StrType方法。 该方法与用于注释类型的_NewType方法没有什么不同,但是该类的定义已更改:我们不仅存储字符串本身,还存储应该在其上编写的制表符级别。 缩进的数量定义如下:如果在函数中遇到此行,则在一个函数中遇到此行,如果在方法中遇到此行,则缩进两个,并且在我们的项目中,在任何情况下都没有将函数定义在另一个函数的主体中并进行修饰的情况。
class StrType(object): def __init__ (self, s, indent): self.s = s self.indent = indent def __repr__ (self): return '"""\n' + self.s + '\n' + ' ' * 4 * self.indent + '"""\n'
再次,我们定义线的外观。 我认为这远非唯一的解决方案,但它可行。 可以尝试使用astunparse.fill和astunparse.Unparser.indent进行试验,这样会更加通用,但是在撰写本文时,我已经想到了这一想法。
这样解决的困难就结束了。 运行脚本后,有时会出现循环导入的问题,但这是体系结构问题。 我没有找到现成的第三方解决方案,并且在我的脚本框架内处理此类情况似乎是任务的严重复杂化。 也许在ast的帮助下,可以检测和解决循环进口问题,但是这个想法需要分开考虑。 通常,在我们的项目中,此类事件的数量微不足道,这使我无法自动处理它们。
我遇到的另一个困难是,天文输入法缺乏ast表达处理能力,因为细心的读者已经知道,猴子贴片可以治愈所有疾病。 让这成为他的家庭作业,但我决定这样做:只需将此类导入添加到映射文件中,因为这种构造通常用于绕过名称冲突,而且我们很少。
尽管发现了缺陷,但脚本仍按预期进行。 结果是什么:
- 项目启动时间从10秒减少到3秒;
- 由于删除了new_contract定义,因此文件数量减少了。 文件本身减少了:我没有测量,但是平均来说git总共增加了n行,删除了2n行。
- 智能IDE开始提出不同的提示,因为现在它们不是注释,而是诚实的导入;
- 可读性得到了提高;
- 出现括号。
谢谢你
有用的链接:
- 阿斯特
- 霍拉斯特
- 所有类型的ast节点及其存储的内容
- 精美显示语法树
- Isort