Python包导入机制
本文的主要参考文献为python的官方文档,针对的python版本为3.14.2
首先一个语言的导入系统是很重要的,因为它决定了代码的模块化以及可复用性,一个好的模块系统可以让开发者更方便地组织和管理代码,提高代码的可维护性和可读性。而我之所以写这么一篇文章,就是因为一直被python的导入系统折磨,因此才下定决定来搞清楚python的模块系统与导入机制。
对于python来说,我们最常用的导入方式是使用import语句,但是其实除了import语句之外,还有很多其他的导入方式,比如importlib.import_module()以及内置的__import__()函数等。
而import语句主要做了两件事情:
- 搜索指定名称的模块(就是调用
__import__())。 - 将搜索的结果绑定到一个local的名称上面。
Example
import spam会产生如下的字节码:
1
spam = import('spam', globals(), locals(), [], 0)
import spam.ham会产生如下的字节码:
1
spam = import('spam.ham', globals(), locals(), [], 0)
from spam.ham import eggs, sausage as saus会产生如下的字节码:
1
2
3_temp = import('spam.ham', globals(), locals(), ['eggs', 'sausage'], 0)
eggs = _temp.eggs
saus = _temp.sausage
而有关importlib的介绍,暂时还不在本文的考虑范围,本文主要针对于import语句。
Packages介绍
python中有模块(module)和包(package)的概念,模块是一个包含python代码的.py文件,而包则是一个包含多个模块的目录。包可以包含子包和模块,从而形成一个层次化的命名空间。除此之外,最早python的包是需要在目录下放置一个__init__.py文件来标识该目录是一个包,但是从python3.3版本开始,这个文件已经不是必须的了(使用__init__.py的是Regular packages,而不使用__init__.py的是Namespace packages,前者需要手动在__init__.py中来维护__path__变量,而后者则是导入机制自动维护)。而且包和模块被导入之后,会定义许多"dunder"(double underscore)的变量,比如__name__,__file__,__path__,__package__等,这些变量在模块和包的导入过程中起着重要的作用。
以__path__为例,该变量就是包(package)被导入时才会定义的变量,其内容为一个列表,包含了该包内部各个模块以及子包(subpackage)的搜索路径,而且可以在包的__init__.py文件中进行修改,从而影响包内部模块的导入行为。
Warning
注意,__path__变量只在包(package)中定义,而在普通的模块(module)中是没有该变量的。
也就是说__path__要么在__init__.py文件中使用,要么import一个包之后,将其作为包的属性来使用。
__path__的示例
假设有如下的包结构:
1
2
3
4my_package/
|-- init.py
|-- module1.py
|-- module2.py
然后执行如下的python脚本:
1
2
3import my_package
print(my_package.__path__)
其输出结果会类似于:
['/path/to/directory/containing/my_package']
而依据该变量的结果,当你导入module1和module2时,python解释器就知道去哪里寻找这些模块。
1
2from my_package import module1
from my_package import module2
修改__path__
之所以修改__path__,是因为这样可以将其他的目录变成包my_package的一部分,从而实现更灵活的模块组织和导入。
继续使用上面的包结构,我们可以在__init__.py文件中修改__path__变量:
1
2
3
4
5# my_package/init.py
import os
# 添加一个新的路径到__path__
path.append(os.path.abspath('../extra_modules'))
这样做了之后,如果extra_modules目录下有一些模块,比如module3.py,我们就可以通过my_package来导入它们:
1
from my_package import module3
导入方式介绍
在使用python的导入功能的时候,我们通常使用的方式是使用import语句,其有两种用法:
1 | # method 1 |
Note
需要注意的是,如果import package_name后面接的是一个包名,那么实际上导入的是该包的__init__.py文件所定义的内容,如果没有在__init__.py文件中进行特殊处理,package_name目录下的子模块和子包是不会被自动导入的,也无法通过package_name.submodule的方式访问。
而python的导入机制主要分为两种:绝对导入和相对导入。
绝对导入
其中绝对导入可以使用上述的两种import用法,具体的使用语法如下:
1 | import package_name.module_name [as alias_name] |
绝对导入的特点在于,其包的搜索路径都是依据sys.path变量的,而默认情况下,sys.path变量包含了当前脚本所在的目录、PYTHONPATH环境变量中指定的目录以及Python安装目录下的标准库目录等。
假设有如下的项目结构:
1 | /project/ |
当我们在/project目录下运行python my_package/module1.py时,sys.path中会包含/project/my_package目录,因此可以正常运行。但是当我们在/project目录下运行python main.py时,sys.path中会包含/project目录,而不包含/project/my_package目录,因此module1.py中的import module2语句会导致导入失败,因为无法查找到module2模块。
可行的的解决办法如下:最推荐的方法是4
- 将
/project/my_package添加到PYTHONPATH环境变量中 - 在
module1.py中,将/project/my_package添加到sys.path中,然后才import module2 - 在
module1.py中使用绝对导入的方式来导入module2模块,例如from my_package import module2。但是这个会导致python my_package/module1.py无法运行。 - 在
module1.py中使用相对导入的方式来导入module2模块,例如from . import module2。这个同样会导致python my_package/module1.py无法运行。但是可以使用python -m my_package.module1来运行。
相对导入
而相对导入只能使用第二种from ... import ...的用法,具体的使用语法如下:其与绝对导入的区别在于,使用相对导入时,模块名称前面会有一个或多个点号(.),单个.表示当前包,两个点号..表示上级包,三个点号...表示上上级包,依此类推。
1 | from .module_name import class_name/function_name [as alias_name] # 导入当前包中的模块 |
而相对导入的另一个问题在于,既然是相对导入,那么相对于谁呢?答案是相对于当前模块所在的包(package)。因此,相对导入只能在包内部使用,不能在顶层模块中使用。
Note
其实相对导入其实是相对于该语句所在的模块(或者说文件)的__package__属性来进行导入的。
The module’s
__package__attribute should be set. Its value must be a string, but it can be the same value as its__name__. If the attribute is set to None or is missing, the import system will fill it in with a more appropriate value. When the module is a package, its__package__value should be set to its__name__. When the module is not a package,__package__should be set to the empty string for top-level modules, or for submodules, to the parent package’s name. See PEP 366 for further details.
简单来说,__package__属性其实就是模块或者包所处的包的名称,如果是顶层模块,则该属性值为'',如果是包,则该属性值为自己的名称,如果是包中的模块,则该属性值为其父包的名称。
而依据上述关于__package__的描述,我们就知道了为什么相对导入只能在包内部使用,而无法在顶层模块中使用,因为顶层模块的__package__属性值为'',相对导入就不知相对于谁了。
还有一个问题在于对于一个写好的包,其内部其实往往都是使用相对导入的,因为这样可以避免包被移动到其他位置时,导入路径出错的问题。但是有时候我们可能需要直接运行该包内部的某个模块(python my_package/module1.py),这时候就会出现相对导入失败的问题。
出现这个问题的原因在于,使用python my_package/module1.py运行模块时,Python并没有模块的概念,其__package__属性值为None,__name__属性值为"__main__",这时候相对导入没有参考点,因此无法正常工作。
而如果通过import my_package.module1来导入该模块时,其__package__属性值为"my_package",__name__属性值为"my_package.module1",只有像这样,__package__有非空的值,相对导入才能正常工作。
这就出现一个很恼人的现象,一个模块可以被main.py导入并正常工作,但是直接运行该模块(比如需要进行测试之类的),却出现ImportError的问题。
而如果通过新建一个main.py,然后通过import来运行(测试)模块的话,不仅很麻烦,而且由于通过import导入的模块,其__name__属性值为"my_package.module1",而不是"__main__",这会导致一些依赖于__name__属性值的代码无法正常工作。
因此,为了解决运行模块的问题,我们可以采用-m选项来运行模块,通过命令行使用python -m my_package.module1来运行模块,这样Python会将模块作为包的一部分来处理,从而正确设置__package__属性,同时其__name__属性值为"__main__",从而依赖于__name__属性值的代码也能正常工作。
Note
需要注意的是,python my_package/module1.py和python -m my_package.module1这两种方式运行模块时,模块的搜索路径(sys.path)是不同的。
- 使用
python my_package/module1.py运行模块时,Python会将module1.py所在的目录添加到sys.path的开头。 - 使用
python -m my_package.module1运行模块时,Python会将该命令运行的目录添加到sys.path的开头。
Note
需要注意的是,python -m main这种情况,Python是有模块的概念的,main.py会被当做顶层模块,其__package__属性值为'',而不是None。但是这种情况仍然是不能使用相对导入的。
实战建议
基于我对于python包导入机制的理解,我总结了如下几点开发建议,以及在实际开发中,推荐使用的目录结构以及导入方法:
- 在同一个包内部相互导入的时候,统一使用相对导入的方法(package与subpackage之间也是使用相对导入)
- 在不同包之间导入的时候,统一使用绝对导入的方法
- 在
main.py等顶层脚本中,使用绝对导入的方法来导入包和模块 - 要单独运行一个package内部的模块时,使用
python -m package.module的方式来运行
而推荐的目录结构如下:
1 | /project/ |
然后不同的模块的运行方式如下:(下面所有的命令都是在/project/目录下运行的)
- 运行顶层脚本
main.py:python main.py或者python -m main - 运行
my_package1包内部的模块module1.py:python -m my_package1.module1 - 运行
my_package1包内部的子包sub_package中的模块sub_module1.py:python -m my_package1.sub_package.sub_module1 - 运行
my_package2包内部的模块moduleA.py:python -m my_package2.moduleA - 运行测试模块
test_module1.py:python -m tests.test_module1
