自动更新Python源代码(导入)



我们正在重构我们的代码库。

旧:

from a.b import foo_method

新增:

from b.d import bar_method

两种方法(foo_method()bar_method())是相同的。它只是更改了包的名称。

由于上面的例子只是导入方法的许多方法中的一个例子,所以我认为一个简单的正则表达式在这里没有帮助。

如何使用命令行工具重构模块的导入?

许多源代码行需要更改,因此IDE在这里没有帮助。

在幕后,IDE只不过是一个文本编辑器,带有一堆窗口和附加的二进制文件,可以进行不同类型的工作,如编译、调试、标记代码、linting等。最终,其中一个库可以用来重构代码。Jedi就是这样一个库,但也有一个专门用于处理重构的库,它就是rope。

pip3 install rope

CLI解决方案

您可以尝试使用他们的API,但由于您要求使用命令行工具,但没有,请将以下文件保存到任何可访问的位置(用户bin的已知相对文件夹等),并使其可执行chmod +x pyrename.py

#!/usr/bin/env python3
from rope.base.project import Project
from rope.refactor.rename import Rename
from argparse import ArgumentParser
def renamodule(old, new):
prj.do(Rename(prj, prj.find_module(old)).get_changes(new))
def renamethod(mod, old, new, instance=None):
mod = prj.find_module(mod)
modtxt = mod.read()
pos, inst = -1, 0
while True:
pos = modtxt.find('def '+old+'(', pos+1)
if pos < 0:
if instance is None and prepos > 0:
pos = prepos+4 # instance=None and only one instance found
break
print('found', inst, 'instances of method', old+',', ('tell which to rename by using an extra integer argument in the range 0..' if (instance is None) else 'could not use instance=')+str(inst-1))
pos = -1
break
if (type(instance) is int) and inst == instance:
pos += 4
break # found
if instance is None:
if inst == 0:
prepos = pos
else:
prepos = -1
inst += 1
if pos > 0:
prj.do(Rename(prj, mod, pos).get_changes(new))
argparser = ArgumentParser()
#argparser.add_argument('moduleormethod', choices=['module', 'method'], help='choose between module or method')
subparsers = argparser.add_subparsers()
subparsermod = subparsers.add_parser('module', help='moduledottedpath newname')
subparsermod.add_argument('moduledottedpath', help='old module full dotted path')
subparsermod.add_argument('newname', help='new module name only')
subparsermet = subparsers.add_parser('method', help='moduledottedpath oldname newname')
subparsermet.add_argument('moduledottedpath', help='module full dotted path')
subparsermet.add_argument('oldname', help='old method name')
subparsermet.add_argument('newname', help='new method name')
subparsermet.add_argument('instance', nargs='?', help='instance count')
args = argparser.parse_args()
if 'moduledottedpath' in args:
prj = Project('.')
if 'oldname' not in args:
renamodule(args.moduledottedpath, args.newname)
else:
renamethod(args.moduledottedpath, args.oldname, args.newname)
else:
argparser.error('nothing to do, please choose module or method')

让我们创建一个测试环境,使用问题中显示的场景(这里假设是linux用户):

cd /some/folder/
ls pyrename.py # we are in the same folder of the script
# creating your test project equal to the question in prj child folder:
mkdir prj; cd prj; cat << EOF >> main.py
#!/usr/bin/env python3
from a.b import foo_method
foo_method()
EOF
mkdir a; touch a/__init__.py; cat << EOF >> a/b.py
def foo_method():
print('yesterday i was foo, tomorrow i will be bar')
EOF
chmod +x main.py
# testing:
./main.py
# yesterday i was foo, tomorrow i will be bar
cat main.py
cat a/b.py

现在使用脚本重命名模块和方法:

# be sure that you are in the project root folder

# rename package (here called module)
../pyrename.py module a b 
# package folder 'a' renamed to 'b' and also all references

# rename module
../pyrename.py module b.b d
# 'b.b' (previous 'a.b') renamed to 'd' and also all references also
# important - oldname is the full dotted path, new name is name only

# rename method
../pyrename.py method b.d foo_method bar_method
# 'foo_method' in package 'b.d' renamed to 'bar_method' and also all references
# important - if there are more than one occurence of 'def foo_method(' in the file,
#             it is necessary to add an extra argument telling which (zero-indexed) instance to use
#             you will be warned if multiple instances are found and you don't include this extra argument

# testing again:
./main.py
# yesterday i was foo, tomorrow i will be bar
cat main.py
cat b/d.py

这个例子确实和这个问题一模一样。

只实现了模块和方法的重命名,因为这是问题范围。如果您需要更多,您可以增加脚本或从头开始创建一个新脚本,从他们的文档和脚本本身中学习。为了简单起见,我们使用当前文件夹作为项目文件夹,但您可以在脚本中添加一个额外的参数,使其更加灵活。

您需要编写/找到一些脚本来替换某个文件夹中所有出现的内容。我记得Notepad++可以做到这一点。

但正如您所提到的,regexp在这里不会有帮助,那么没有脚本(甚至是开源)在这里也会有帮助。你肯定需要在这里有一些智能,这将建立你的依赖项/模块/文件/包等的索引。并且将能够在该级别上操纵它们。这就是IDE构建的目的。

您可以选择任何您喜欢的东西:PyCharm、Sublime、Visual Studio或任何其他不仅是文本编辑器,而且具有重构功能的东西。


无论如何,我建议您执行以下重构步骤

  • 将旧方法及其用法重命名为新名称
  • 那么只需替换包路径&具有较新版本的导入中的名称

在没有明显方法解决批量编辑问题的情况下,添加一些手动工作来做下一件最好的事情也可以。

正如你在帖子中提到的:

由于上面的例子只是导入方法的许多方法中的一个例子,我认为一个简单的正则表达式在这里没有帮助。

我建议使用正则表达式,同时仍然打印出潜在的匹配项,以防它们相关:

def potential(line):
# This is just a minimal example; replace with more reliable expression
return "foo_method" in line or "a.b" in line 
matches = ["from a.b import foo_method"] # Add more to the list if necessary
new = "from b.d import bar_method" 
# new = "from b.d import bar_method as foo_method"
file = "file.py"
result = ""
with open(file) as f:
for line in f:
for match in matches:
if match in line:
result += line.replace(match, new)
break
else:
if potential(line):
print(line)
# Here is the part where you manually check lines that potentially needs editing
new_line = input("Replace with... (leave blank to ignore) ")
if new_line:
result += new_line + "n"
continue
result += line

with open(file, "w") as f:
f.write(result) 

此外,这是不言而喻的,但在进行此类更改之前,始终确保至少创建一个原始代码库/项目的备份

但我真的不认为在导入方法的不同方式上会有太多的复杂性,因为代码库是在正确的PEP-8中开发的,比如在Python中导入模块的所有方式都是什么?:

对于普通使用来说,唯一重要的方法是该页面上列出的前三种方法:

  • import module
  • from module import this, that, tother
  • from module import *

最后,为了避免将调用foo_method的文件从foo_method重命名为bar_method的每个实例时出现复杂情况,我建议使用as关键字将新命名的bar_method导入为foo_method

编程解决方案是将每个文件转换为语法树,识别符合标准的部分并对其进行转换。您可以使用Python的ast模块来实现这一点,但它不保留空白或注释。还有一些库保留了这些特性,因为它们在具体的(或无损的)语法树而不是抽象的语法树上操作。

Red Baron就是这样一个工具,但它不支持Python 3.8+,而且看起来是未维护的(上一次提交是在2019年)。libcst是另一个,我将在这个答案中使用它(免责声明:我与libcst项目无关)注意,libcst还不支持Python 3.10+。

下面的代码使用一个可以识别的转换器

  • from a.b import foo_method语句
  • 函数调用,其中函数命名为foo_method

并将识别的节点转换为

  • from b.d import bar_method
  • bar_method

在transformer类中,我们指定了名为leave_Node的方法,其中Node是我们要检查和转换的节点类型(我们也可以指定visit_Node方法,但本例不需要它们)。在这些方法中,我们使用匹配器来检查节点是否符合我们的转换标准。

import libcst as cst
import libcst.matchers as m

src = """
import foo
from a.b import foo_method

class C:
def do_something(self, x):
return foo_method(x)
"""

class ImportFixer(cst.CSTTransformer):
def leave_SimpleStatementLine(self, orignal_node, updated_node):
"""Replace imports that match our criteria."""
if m.matches(updated_node.body[0], m.ImportFrom()):
import_from = updated_node.body[0]
if m.matches(
import_from.module,
m.Attribute(value=m.Name('a'), attr=m.Name('b')),
):
if m.matches(
import_from.names[0],
m.ImportAlias(name=m.Name('foo_method')),
):
# Note that when matching we use m.Node,
# but when replacing we use cst.Node.
return updated_node.with_changes(
body=[
cst.ImportFrom(
module=cst.Attribute(
value=cst.Name('b'), attr=cst.Name('d')
),
names=[
cst.ImportAlias(
name=cst.Name('bar_method')
)
],
)
]
)
return updated_node
def leave_Call(self, original_node, updated_node):
if m.matches(updated_node, m.Call(func=m.Name('foo_method'))):
return updated_node.with_changes(func=cst.Name('bar_method'))
return updated_node

source_tree = cst.parse_module(src)
transformer = ImportFixer()
modified_tree = source_tree.visit(transformer)
print(modified_tree.code)

输出:

import foo
from b.d import bar_method

class C:
def do_something(self, x):
return bar_method(x)

您可以在Python REPL中使用libcst的解析助手来查看和处理模块、语句和表达式的节点树。这通常是确定要转换哪些节点以及需要匹配哪些节点的最佳方法。

libcst提供了一个名为codemods的框架来支持重构大型代码库。

最新更新