深入解析 Python 包调用原理与最佳实践

深入解析 Python 包调用原理与最佳实践

一、引言#写下这篇文章的起因,是最近我在参与 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 :

package_a.package_b :

package_a.package_c :

-----------------------------

globals():

__name__ : __main__

__package__ : None

...

package_a :

-----------------------------

locals():

__name__ : __main__

__package__ : None

...

package_a :

可以看到,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 :

package_a.package_b :

package_a.package_c :

-----------------------------

globals():

...

package_b :

package_c :

-----------------------------

locals():

...

package_b :

package_c :

可以看到,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 call b() rather than a().

补充实验:

我们修改 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 :

package_a.package_b :

-----------------------------

globals():

...

package_a :

package_b :

-----------------------------

locals():

...

package_a :

package_b :

实验结论:

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 :

package_a.package_b.test_b :

package_a :

-----------------------------

globals():

...

package_b :

-----------------------------

locals():

...

package_b :

可以看到,此时 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 以及其中的类。七、参考资料#python 中的模块、库、包有什么区别?- 风影忍着的回答 - 知乎python 中的模块、库、包有什么区别?- 看图学的回答 - 知乎[Python 库调用和管理] __init__.py 的基本使用和运作机制Python Tips: __init__.py 的作用from ... import vs import .python importlib 用法小结

相关文章

⛏ NVIDIA GTX 1060 6GB挖矿性能和算力
365bet365.com

⛏ NVIDIA GTX 1060 6GB挖矿性能和算力

⌛ 07-17 👁️‍🗨️ 3748
导购和销售有什么区别
365平台被黑

导购和销售有什么区别

⌛ 07-25 👁️‍🗨️ 7193
世界女排聯賽|中國女排旗開得勝 今晚鬥保加利亞(附直播連結)
ppt裁剪功能在哪里?为什么没有裁剪选项?
365bet365.com

ppt裁剪功能在哪里?为什么没有裁剪选项?

⌛ 07-02 👁️‍🗨️ 636
关于《饥荒:联机版》核心玩法的探讨
365提供参考号BAVF

关于《饥荒:联机版》核心玩法的探讨

⌛ 07-01 👁️‍🗨️ 8120
美迪手机(了解美迪手机的优点和特色,发现最佳的智能手机体验)