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

5
我们正在重构我们的代码库。
旧版:
from a.b import foo_method

新的:

from b.d import bar_method

这两个方法(foo_method()bar_method())是相同的。只是名称和包名发生了变化。

由于上面的例子只是方法导入方式的众多示例之一,我认为简单的正则表达式在这里无法帮助。

如何通过命令行工具重构导入模块?

许多源代码行需要更改,因此IDE无法帮助处理此问题。


4
Jetbrains的智能重构和预览可能是一个解决方案;你考虑过吗?我看到了你帖子的最后一句话,但是我们最近在一个大型解决方案中进行了类似的改变,涉及数十万行代码,我们花费了几天时间使用预览验证一切是否正确,并且在此之后使用Jetbrains重构功能成功执行了重构。以下是文档:https://www.jetbrains.com/help/idea/refactoring-source-code.html#refactoring_conflicts 你有什么想法? - Simon Provost
@SimonProvost 我的直觉更倾向于使用开源命令行工具。 - guettli
可以理解,就我所知,我不知道其他更好的替代方案。祝你好运。 - Simon Provost
@guettli 有任何限制吗?例如,是否保证该方法始终直接导入,而不是通过模块对象访问?即像 import a.b; foo = a.b.foo_method 这样的东西不存在?或者 getattr(a.b, 'foo_method')?或者调用 exec - a_guest
@guettli 对于捕获非显而易见的用法,测试驱动方法如何?即构建一个系统,反复运行测试套件,并观察ImportErrorAttributeError。然后可以使用这些信息来更新相应的文件,或者至少通知开发人员进行更新。当然,剩下的用法不应该太多,否则这将是非常计算密集型的。否则,您还可以尝试ast解析以识别重构的可能候选项。 - a_guest
显示剩余2条评论
4个回答

2
在无法明显解决批量编辑问题的情况下,通过添加一些手动工作来做到尽力而为也可以取得良好的效果。
正如您在帖子中提到的:
“由于上面的示例只是许多导入方法之一,我认为简单的正则表达式在这里无法帮助。”
我建议使用正则表达式,同时打印出可能匹配的结果以防它们相关:
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_methodbar_method,我建议使用as关键字将新命名的bar_method作为foo_method导入。

2

幕后,集成开发环境(IDE)不过是带有许多窗口和附加二进制文件以执行各种任务的文本编辑器,例如编译、调试、代码标记、静态代码分析等。最终,其中一个库可以用于重构代码。其中一个这样的库是Jedi,但有一个专门用于处理重构的库,它是rope

pip3 install rope

命令行解决方案

你可以尝试使用他们的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

这个例子完全做到了问题所要求的。

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


2
你需要编写或找到一些脚本,可以在某个文件夹中替换所有出现的文本。我记得Notepad++可以做到这一点。
但正如你所提到的,那个正则表达式在这里没有用处,那么即使是开源的脚本也无法帮助你。你肯定需要一些智能技术,在依赖、模块、文件、包等级别上构建索引,并能够在该级别上进行操作。这就是IDE的目的所在。
你可以选择任何你喜欢的IDE:PyCharm、Sublime、Visual Studio或其他不仅仅是文本编辑器而且具有重构功能的工具。
无论如何,我建议你执行以下重构步骤
  • 将旧方法及其使用重命名为新名称
  • 然后只需用更新版本替换导入中的包路径和名称

1
一个编程解决方案是将每个文件转换为语法树,识别符合您标准的部分并进行转换。您可以使用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
在转换器类中,我们指定了方法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 的框架,以支持重构大型代码库。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接