自动更新 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_method
从 foo_method
到 bar_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 中使用 libcst
的 parsing 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
这个例子完全符合问题的要求。
只实现了模块和方法的重命名,因为这是问题范围。如果您需要更多,您可以增加脚本或从头开始创建一个新脚本,从他们的文档和这个脚本本身中学习。为简单起见,我们使用当前文件夹作为项目文件夹,但您可以在脚本中添加一个额外的参数以使其更加灵活。
我们正在重构我们的代码库。
旧:
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_method
从 foo_method
到 bar_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+.
下面的代码使用了一个可以识别
的Transformerfrom 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 中使用 libcst
的 parsing 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
这个例子完全符合问题的要求。
只实现了模块和方法的重命名,因为这是问题范围。如果您需要更多,您可以增加脚本或从头开始创建一个新脚本,从他们的文档和这个脚本本身中学习。为简单起见,我们使用当前文件夹作为项目文件夹,但您可以在脚本中添加一个额外的参数以使其更加灵活。