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

Automatically Update Python source code (imports)

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

旧:

from a.b import foo_method

新:

from b.d import bar_method

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

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

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

需要更改大量源代码行,因此 IDE 在这里无济于事。

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

但是正如您提到的,正则表达式在这里无济于事,那么脚本(甚至开源)也无济于事。你肯定需要在这里有一些智慧,这将建立你的 dependencies/modules/files/packages/etc 的索引。并将能够在那个级别上操纵它们。这就是构建 IDE 的目的。

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


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

  • 将旧方法及其用法重命名为新名称
  • 然后只需将导入中的包路径和名称替换为较新的版本

如果没有明显的方法来解决批量编辑问题,通过添加一些手动工作来做次优的事情也同样有效。

正如您在 post 中提到的那样:

Since above example is just one example of many ways a method can be imported, I don't think a simple regular expression can help here.

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

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) 

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

但我真的不认为在导入方法的不同方式上会有太多复杂性,因为代码库是在适当的 PEP-8 中开发的,因为来自 What are all the ways to import modules in Python?:

The only ways that matter for ordinary usage are the first three ways listed on that page:

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

最后,为了避免重命名每个实例的复杂性,文件调用 foo_methodfoo_methodbar_method,我建议导入新命名的 bar_method asfoo_method,当然使用as关键字。

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

Red Baron is one such tool, but it does not support Python 3.8+, and looks to be unmaintained (last commit in 2019). libcst is another, and I'll use it in this answer (disclaimer: I am not associated with the libcst project). Note that libcst does not yet support Python 3.10+.

下面的代码使用了一个可以识别

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

并将识别出的节点转换为

  • from b.d import bar_method
  • bar_method

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

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 中使用 libcstparsing helpers 来查看和使用模块、语句和表达式的节点树。这通常是确定要变换哪些节点以及需要匹配哪些节点的最佳方法。

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

在幕后,IDE 只不过是带有一堆 windows 和附加二进制文件的文本编辑器,用于执行不同类型的工作,如编译、调试、标记代码、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

这个例子完全符合问题的要求。

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