一、引言#
写下这篇文章的起因,是最近我在参与 vLLM 项目的开发过程中,发现其中使用了一种动态加载对象的方式值得学习,并由此想对 Python 语言加载依赖的方式做一个研究和总结。本文将通过实验的方式,对 Python import 的原理以及不同 import 方式的区别进行介绍,并针对具体开发过程中可能会遇到的一些问题,分享一些最佳实践的解决方案。
二、什么是 Python 的包?#
Python 中的模块、包以及库有什么区别?
一种简单且直观的理解:
- 模块(module):任何
.py
文件都可以作为一个“模块”(除了.py
文件之外,模块还可以有其它形式); - 包(package):任何包含了一个
__init__.py
文件的文件夹都是一个“包”,一个包里可以包含其它的包和模块; - 库(library):“库”更多地是一种编程上的概念,表示可重复利用的代码。
三、深入解析 import 原理#
下面我将通过一个个具体的实验,来对 Python import 的原理进行深入的研究和探索。
3.1 实验一#
首先,我们设置代码的目录结构如下:
.
|-- main.py
`-- package_a
|-- __init__.py
|-- package_b
| `-- test_b.py
`-- package_c
`-- test_c.py
main.py
:
import package_a
def print_dir(dirs, name):
'''
dir() 返回一个字典,包含传入对象的所有属性和方法
'''
print("-----------------------------")
print("dir(" + name + "):")
for dir in dirs:
print(dir)
print_dir(dir(), "main")
package_a/__init__.py
:
print("package_a has been imported.")
运行 main.py
,输出结果如下:
package_a has been imported.
-----------------------------
dir(main):
__annotations__
__builtins__
__cached__
__doc__
__file__
__loader__
__name__
__package__
__spec__
package_a
print_dir
实验结论:
- 当我们
import package_a
时,package_a/__init__.py
文件中的内容会被执行; - import 后的内容(
package_a
)会被添加到当前文件的属性中,我们可以在main.py
中直接调用package_a
; - 只有
package_a
被 import 了,但其中的package_b
和package_c
没有被 import 到。
补充说明:
__init__.py
文件存在于一个需要作为 Python 包被调用的文件夹下,该文件夹可以被 import 到任何 Python 工程中。当我们执行 import package
时,Python 程序将运行 __init__.py
中所有的命令,并将所有与 package 模块相关的对象记录。如果 __init__.py
为空,则生成一个空的 package 对象,它是无法自动处理文件夹下的其他文件的。
那么我们要怎么才能将 package_b
和 package_c
也 import 到 main.py
中呢?
3.2 实验二#
我们修改 main.py
文件如下:
import sys
import package_a.package_b
import package_a.package_c
def print_dir(dirs, name):
print("-----------------------------")
print("dir(" + name + "):")
for dir in dirs:
print(dir)
print_dir(dir(), "main")
'''
sys.modules 是一个全局字典,该字典从 python 启动后就加载在内存中。
每当我们导入一个新的模块,sys.modules 就会记录这些模块。
当某个模块第一次被导入时,sys.modules 将自动记录该模块;当第二次再导入该模块时,python 会直接到字典中查找,从而加快程序运行的速度。
'''
print("-----------------------------")
print("sys.modules:")
for k, v in sys.modules.items():
print(k, ":", v)
'''
globals() 会以字典类型返回当前位置的全部全局变量
'''
print("-----------------------------")
print("globals():")
for k, v in globals().items():
print(k, ":", v)
'''
locals() 会以字典类型返回当前位置的全部局部变量
'''
print("-----------------------------")
print("locals():")
for k, v in locals().items():
print(k, ":", v)
增加 package_b/__init__.py
文件:
print("package_b has been imported.")
增加 package_c/__init__.py
文件:
print("package_c has been imported.")
运行 main.py
,输出结果如下:
package_a has been imported.
package_b has been imported.
package_c has been imported.
-----------------------------
dir(main):
...
package_a
-----------------------------
sys.modules:
...
package_a : <module 'package_a' from '/.../package_a/__init__.py'>
package_a.package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
package_a.package_c : <module 'package_a.package_c' from '/.../package_a/package_c/__init__.py'>
-----------------------------
globals():
__name__ : __main__
__package__ : None
...
package_a : <module 'package_a' from '/.../package_a/__init__.py'>
-----------------------------
locals():
__name__ : __main__
__package__ : None
...
package_a : <module 'package_a' from '/.../package_a/__init__.py'>
可以看到,package_b
和 package_c
被成功 import 了,但没有被添加到 main.py
的属性中,不能直接被访问。
我们再修改 main.py
文件如下:
import sys
from package_a import package_b
from package_a import package_c
# 其余内容保持不变……
运行 main.py
,输出结果如下:
package_a has been imported.
package_b has been imported.
package_c has been imported.
-----------------------------
dir(main):
...
package_b
package_c
-----------------------------
sys.modules:
...
package_a : <module 'package_a' from '/.../package_a/__init__.py'>
package_a.package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
package_a.package_c : <module 'package_a.package_c' from '/.../package_a/package_c/__init__.py'>
-----------------------------
globals():
...
package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
package_c : <module 'package_a.package_c' from '/.../package_a/package_c/__init__.py'>
-----------------------------
locals():
...
package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
package_c : <module 'package_a.package_c' from '/.../package_a/package_c/__init__.py'>
可以看到,package_b
和 package_c
同样被成功 import 了,并且还被添加到了 main.py
的属性中,但 package_a
属性没了。
实验结论:
使用 import package_a.package_b
或 from package_a import package_b
都可以将 package_b
给 import 进来,但两种方式的区别如下:
- 第一种方式是将
package_a
给添加到当前文件的属性中,我们可以直接调用的是package_a
。若想进一步访问其中的内容,我们可以使用package_a.xxx
的方式; - 第二种方式是将 import 后的内容(
package_b
和package_c
)给添加到当前文件的属性中,而不添加 from 后的内容(package_a
),我们可以直接访问的是package_b
。若想进一步访问package_b
中的内容,我们可以使用package_b.xxx
的方式,而不需要使用package_a.package_b.xxx
(因为此时我们访问不到package_a
变量)。
补充说明:
import ...
vs from ... import ...
:
import ...
是间接调用,当我们要使用 package 内的方法或对象(X
)时,需要使用package.X
的方式来访问;from ... import X
是直接调用,我们可以直接使用X
来调用X
方法或对象。
这里再补充一段 stack overflow 上的解释:
import X
: Imports the module X, and creates a reference to that module in the current namespace. Then you need to define completed module path to access a particular attribute or method from inside the module (e.g.: X.name or X.attribute).
from X import *
: Imports the module X, and creates references to all public objects defined by that module in the current namespace (that is, everything that doesn’t have a name starting with _) or whatever name you mentioned. In other words, after you’ve run this statement, you can simply use a plain (unqualified) name to refer to things defined in module X. But X itself is not defined, so X.name doesn’t work. And if name was already defined, it is replaced by the new version. And if name in X is changed to point to some other object, your module won’t notice.
from X import a as b
: You can directly callb()
rather thana()
.
补充实验:
我们修改 main.py
文件如下:
import sys
import package_a
from package_a.package_b.test_b import test
def test():
print("call test() in main.")
package_a.package_b.test_b.test()
test()
package_b/test_b.py
:
def test():
print("call test() in package_b.")
运行 main.py
,输出结果如下:
call test() in package_b.
call test() in main.
可以看到,当我们使用 from ... import ...
的方式将 test
import 到 main.py
文件中时,该函数会被我们在当前文件中定义的同名函数给覆盖(就近原则)。
3.3 实验三#
我们修改 main.py
文件如下:
import sys
import package_a
from package_a import *
def print_dir(dirs, name):
print("-----------------------------")
print("dir(" + name + "):")
for dir in dirs:
print(dir)
print_dir(dir(), "main")
print_dir(dir(package_a), "package_a")
print("-----------------------------")
print("sys.modules:")
for k, v in sys.modules.items():
print(k, ":", v)
print("-----------------------------")
print("globals():")
for k, v in globals().items():
print(k, ":", v)
print("-----------------------------")
print("locals():")
for k, v in locals().items():
print(k, ":", v)
修改 package_a/__init__.py
文件如下:
print("package_a has been imported.")
__all__ = ['package_b']
运行 main.py
,输出结果如下:
package_a has been imported.
package_b has been imported.
-----------------------------
dir(main.py):
...
package_a
package_b
-----------------------------
dir(package_a):
...
package_b
-----------------------------
sys.modules:
...
package_a : <module 'package_a' from '/.../package_a/__init__.py'>
package_a.package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
-----------------------------
globals():
...
package_a : <module 'package_a' from '/.../package_a/__init__.py'>
package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
-----------------------------
locals():
...
package_a : <module 'package_a' from '/.../package_a/__init__.py'>
package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
实验结论:
from package_a import *
语句会将package_a/__init__.py
中__all__
变量里的包(package_b
)给 import 到当前文件中,而package_c
由于没有被添加到__all__
里面,因此不会被 import;package_b
同时还会被添加到package_a
的属性中。
补充说明:
__init__.py
文件中的 __all__
变量关联了一个模块列表,当执行 from ... import *
时,就会导入该列表中的所有模块,并且该操作还会继续查找 package_b
中的 __init__.py
并执行。
3.4 实验四#
修改 package_a/__init__.py
文件如下:
from package_b import test_b
print("package_a has been imported.")
__all__ = ['package_b']
运行 main.py
,输出结果如下:
package_a has been imported.
...
ModuleNotFoundError: No module named 'package_b'
可以看到,此时 Python 程序显示找不打 package_b
,import 失败,为什么呢?我们继续实验看看结果。
修改 package_a/__init__.py
文件如下:
from package_a.package_b import test_b
print("package_a has been imported.")
__all__ = ['package_b']
运行 main.py
,输出结果如下:
package_a has been imported.
package_b has been imported.
-----------------------------
dir(main.py):
...
package_a
package_b
-----------------------------
dir(package_a):
...
package_b
test_b
-----------------------------
sys.modules:
...
package_a.package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
package_a.package_b.test_b : <module 'package_a.package_b.test_b' from '/.../package_a/package_b/test_b.py'>
package_a : <module 'package_a' from '/.../package_a/__init__.py'>
-----------------------------
globals():
...
package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
-----------------------------
locals():
...
package_b : <module 'package_a.package_b' from '/.../package_a/package_b/__init__.py'>
可以看到,此时 test_b
终于被成功 import 到了 package_a
中。
实验结论:
当我们在 main.py
中执行 import 时,当前目录是不会变的,所以当我们需要在 package_a
中 import package_b
时,必须使用完整的包名(package_a.package_b
)。
四、循环依赖问题#
4.1 实验五#
首先,我们设置代码的目录结构如下:
.
|-- main.py
|-- package_a
| |-- __init__.py
| `-- func_a.py
`-- package_b
|-- __init__.py
`-- func_b.py
main.py
:
import package_a
package_a/__init__.py
:
from package_a import func_a
package_a/func_a.py
:
from package_b.func_b import function_b
def function_a():
print("call function_a().")
function_b()
package_b/__init__.py
:
from package_b import func_b
package_b/func_b.py
:
from package_a.func_a import function_a
def function_b():
function_a()
print("call function_b().")
运行 main.py
,输出结果如下:
...
ImportError: cannot import name 'function_a' from partially initialized module 'package_a.func_a' (most likely due to a circular import)
可以看到,此时 Python 程序 import 报错,这是因为我们在 package_a/func_a.py
中引入了 package_b
的同时,又在 package_b/func_b.py
中引入了 package_a
,从而导致了循环依赖问题。
实验结论:
当我们在两个不同的 package 中互相 import 对方时,就会导致循环依赖问题。
4.2 实验六#
我们修改 main.py
文件如下:
import package_a
import package_b
package_a.func_a.get_class()
package_b.func_b.get_class()
修改 package_a/func_a.py
文件如下:
from package_b.func_b import B
class A:
def __init__(self):
pass
def get_class() -> B:
print("get class B from package_b.")
修改 package_b/func_b.py
文件如下:
from package_a.func_a import A
class B:
def __init__(self):
pass
def get_class() -> A:
print("get class A from package_a.")
运行 main.py
,输出结果如下:
...
ImportError: cannot import name 'function_a' from partially initialized module 'package_a.func_a' (most likely due to a circular import)
可以看到,这里同样是一个循环依赖的问题,那么我们应该如何解决这类问题呢?
这里我们可以使用 typing
库中的 TYPE_CHECKING
来解决循环依赖的问题。
修改 package_a/func_a.py
文件如下:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from package_b.func_b import B
class A:
def __init__(self):
pass
def get_class() -> B:
print("get class B from package_b.")
修改 package_b/func_b.py
为:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from package_a.func_a import A
class B:
def __init__(self):
pass
def get_class() -> A:
print("get class A from package_a.")
运行 main.py
,输出结果如下:
...
def get_class() -> B:
NameError: name 'B' is not defined
这里报错的原因是 get_class() ->
后的 A
和 B
没有使用引号包裹。
修改 package_a/func_a.py
文件如下:
# 其余内容保持不变……
def get_class() -> "B":
print("get class B from package_b.")
修改 package_b/func_b.py
文件如下:
# 其余内容保持不变……
def get_class() -> "A":
print("get class A from package_a.")
运行 main.py
,输出结果如下:
get class B from package_b.
get class A from package_a.
可以看到,get_class()
函数调用成功,不存在循环依赖问题。
实验结论:
当两个 Python 文件互相导入引用时,如果不使用 if typing.TYPE_CHECKING:
包裹就直接导入,就会因为循环导入而产生错误。
使用 TYPE_CHECKING
导入的任何对象,只能作为注解使用,不可以真的去使用这些对象,因为这些对象只有在编辑器检查的阶段才会被导入,并且在使用这些类型作为解注时,必须使用引号包裹。否则在真正的代码业务执行时,就会抛出 NameError: xxx is not defined
错误。
补充说明:
TYPE_CHECKING
是一个会被第三方静态类型检查器假定为 True
的特殊常量,而在运行时则会被假定为 False
,也就是它下面的 import
是不执行的,但它可以为第三方静态类型检查器提供所需要检查的类型。关于 TYPE_CHECKING
的更多细节可以自行查阅了解。
五、动态加载对象#
最后,介绍一下我在 vLLM 项目中看到的一种动态加载对象的方式。
vllm/utils.py/resolve_obj_by_qualname()
函数定义如下:
def resolve_obj_by_qualname(qualname: str) -> Any:
"""
Resolve an object by its fully qualified name.
"""
module_name, obj_name = qualname.rsplit(".", 1)
module = importlib.import_module(module_name)
return getattr(module, obj_name)
该函数会接收一个类的全路径名(比如:vllm.worker.multi_step_worker.MultiStepWorker
),然后将该字符串拆分为两部分,分别表示该类所在的 package(vllm.worker.multi_step_worker
)以及该类(MultiStepWorker
)。最后,该函数会加载对应的 package 并返回对应的类。
根据情况返回不同的 Worker
:
@classmethod
def check_and_update_config(cls, vllm_config: VllmConfig) -> None:
parallel_config = vllm_config.parallel_config
scheduler_config = vllm_config.scheduler_config
if parallel_config.worker_cls == "auto":
if scheduler_config.is_multi_step:
if envs.VLLM_USE_V1:
raise NotImplementedError
else:
parallel_config.worker_cls = "vllm.worker.multi_step_worker.MultiStepWorker"
elif vllm_config.speculative_config:
if envs.VLLM_USE_V1:
raise NotImplementedError
else:
parallel_config.worker_cls = "vllm.spec_decode.spec_decode_worker.create_spec_worker"
parallel_config.sd_worker_cls = "vllm.worker.worker.Worker"
else:
if envs.VLLM_USE_V1:
parallel_config.worker_cls = "vllm.v1.worker.gpu_worker.Worker"
else:
parallel_config.worker_cls = "vllm.worker.worker.Worker"
根据情况动态加载不同的 Worker
对象:
from vllm.utils import (resolve_obj_by_qualname, ...)
def init_worker(self, *args, **kwargs):
# ...
worker_class = resolve_obj_by_qualname(self.vllm_config.parallel_config.worker_cls)
self.worker = worker_class(*args, **kwargs)
assert self.worker is not None
使用这种方式,我们不需要在 check_and_update_config()
函数所在的文件中将所有的 Worker
类都 import 进来,而只需要以纯字符串的形式返回对应的类,避免了可能存在的循环依赖问题,从而极大地提升了依赖管理的灵活性。
六、总结#
- 将
__init__.py
文件放到一个文件夹中,使其可以作为一个 python package 被 import。当该 package 被 import 时,__init__.py
文件中的内容将会被执行; import X
与from X import xxx
的区别:第一种对应的调用方式为X.xxx()
;第二种对应的调用方式为xxx()
;__init__.py
文件中的__all__
变量关联了一个模块列表,当执行from ... import *
时,就会导入该列表中的所有模块;- 当我们执行 import 时,当前目录是不会变的,因此需要指定完整的包名;
- 当我们在两个不同的 package 中互相 import 对方时,就会导致循环依赖问题;
- 使用
TYPE_CHECKING
导入的任何对象,只能作为注解使用,不可以真的去使用这些对象,因为这些对象只有在编辑器检查的阶段才会被导入,并且在使用这些类型作为解注时,必须使用引号包裹; - 可以使用
importlib.import_module(module_name)
的方式动态加载我们所需的 package 以及其中的类。