Python 学习笔记——6. 模块和包
Python 除了在解释器中实时执行,或者单个脚本执行,在复杂程序中也有必要对代码进行组织封装,这就是 Python 的模块文件,与模块相对的,称导入并使用模块的脚本为主文件。(这里主要讨论 Python 源码模块,Python 的部分内置模块并不适用)
Python 的模块和包的导入语句其实设计的并不好。
模块例子
一个模块的简单例子如下,由一个 demo.py 文件组成,此时 demo 就是模块名。模块的内容包括其中定义的函数以及可执行的语句等。
1 | # demo.py |
在解释器或者其它脚本中,使用如下语句可以导入模块
1 | import demo |
导入模块的过程本质上就是把这个脚本执行了一遍,并且定义了 demo 这个模块变量,
然后可以通过 demo.s 和 demo.fib 等访问模块中的变量和函数。
也可以完全导入模块中的符号
1 | from demo import * |
然后就可以直接使用模块中定义的 s 和 fib 等标识符。
模块查找路径
解释器通过以下路径查找模块:
- 输入脚本的目录(或未指定文件时的当前目录)
PYTHONPATH环境变量(目录列表,通常用于在调试时临时添加一个搜索路径)- 依赖于安装的默认值(按照惯例包括一个 site-packages 目录,由 site 模块处理)
可以使用如下语句打印当前状态的所有的查找路径
1 | import sys |
可以利用这个机制使用自定义的模块查找路径
1 | import sys |
更常见的做法是使用环境变量添加
1 | PYTHONPATH=/my/path/to/sth python script.py |
导入模块
导入模块的语法非常灵活,最基本的用法为
1 | import demo |
此时可以使用 demo 这个模块变量,模块内的所有标识符可以通过模块名访问,例如 demo.s、demo.fib1 和 demo.fib2。
但是这种写法使得比较繁琐,可以直接导入到当前作用域
1 | from demo import * |
此时不可以使用 demo 这个模块变量,但是模块内的所有标识符可以直接访问,例如 s、fib1 和 fib2。
有时并不需要所有标识符,可以部分导入
1 | from demo import fib,fib2 |
此时不可使用 demo 这个模块变量,但是可以使用其中的 fib、fib2 变量。
标识符的导入存在冲突风险,如果不同模块中存在同名的标识符,并且被完全导入,那么后导入的会覆盖先导入的,导致未知错误。因此不建议全部导入,按需导入即可。
导入标识符时更建议使用别名,既可以规避重名冲突,也可以简化标识符,例如:
- 对整个模块使用别名
import numpy as np - 导入单个标识符时使用别名
from numpy import linspace as ls
关于模块对外暴露的标识符,还有一些细节值得讨论:
- 使用
*导入所有标识符的语句并不会导入任何下划线开头的标识符,因为它们被视作私有的,不会自动导入 - 可以自定义
__all__这个特殊变量来指定对外暴露的所有标识符列表
可以使用如下 dir(<module>) 命令可以查看模块中定义的所有标识符信息(包括内置的标识符),例如
1 | dir(demo) |
这里有很多特殊标识符:
__name__: 模块名__file__: 模块文件路径__doc__: 模块文档字符串__spec__: 模块的导入元数据__cached__: .pyc 缓存路径
解释器在一个会话中不会重复导入同一个模块,而是在导入后进行缓存,如果在导入模块之后进行修改,需要重启解释器或者强制加载才能体现修改。
1 | import xxx |
对于 jupyter notebook,还有一种更优雅的方式
1 | %load_ext autoreload |
虽然通常在 py 脚本开头导入需要的模块,但是 python 也允许延迟到必要时再导入模块,例如
1 | def func(): |
注意此时导入模块的生效范围也是局部的。
在 py 脚本中导入源代码模块(而非内置模块),python 会在源码模块目录中生成 __pycache__/ 目录,并且在其中存放 .pyc 文件,
这是对应模块生成的字节码文件,被缓存用于优化模块的导入速度。
事实上,我们也可以手动控制这个过程,例如将 py 脚本 hello.py 转换为字节码文件 __pycache__/hello.xxx.pyc 并执行(xxx 对应 Python 版本)
1 | python -m py_compile ./hello.py |
执行 pyc 文件和 py 文件的效果是一样的
1 | python ./hello.py |
注意:
- 通常会将
__pycache__/目录和缓存文件从版本管理中排除; - 除了
.pyc文件,还有一个可能见到的.pyd文件,这是 Python 的动态模块,本质就是 Windows 平台的动态链接库(DLL),它们通常用 C 或 C++ 直接编写,可以被 Python 直接导入使用; - 在修改模块后,为了确保修改生效,最好也删除缓存。
避免模块执行
在导入模块时,会依次执行模块的所有语句,例如前文中的 demo.py 文件代表的 demo 模块,导入时会自动执行一次赋值语句 s=1。
一个常见的需求是:我们并不希望作为模块模块时执行特定语句,而是仅仅在当前文件作为主文件时才执行。
可以使用如下语句完成这样的效果,这里我们参考 C++ 的习惯,也定义一个 main() 函数
1 | def main(): |
此时,如果通过解释器导入模块,或者在脚本中导入模块,都不会显示欢迎信息,但是在命令行直接执行这个脚本,就会显示欢迎信息
1 | python demo.py |
注意:使用命令行选项 -m 选项时也会执行,因为此时仍然是入口脚本
1 | python -m demo |
原理是:入口脚本的 __name__ 值为 "__main__",在导入的模块中,__name__ 值为模块名。
包
pip 或者 conda 下载的 Python 源码都是以包为单位的。
包的结构与导入
模块对应的是一个单独的 py 文件,与之相对的,Python 的包则对应了一个文件夹结构,在包的顶级目录下含有特殊的 __init__.py 文件,以及其它若干 py 文件或文件夹,
对于 py 文件,视作一个个模块,对于含有 __init__.py 文件的子文件夹,则视作一个子包。
import 命令在导入模块会执行对应的 py 脚本,在导入包时会执行包的 __init__.py 脚本。
考虑如下的文件结构
1 | Demo/ |
对应的是一个名为 Demo 的包,含有 Demo.Ada 和 Demo.Bob 两个子包,包含 Demo.core 和 Demo.Ada.a1 等四模块,下面以此为例,列举常见的包导入操作。
import 命令可以直接导入包或子包
1 | import Demo |
需要特别注意的是:包在 pip/conda 安装时的名称可能和
import时使用的名称不一致,常见的变体规则是把-改成_。
在导入子包时,如果父包尚未导入,也会被自动导入
1 | import Demo.Ada |
还可以直接导入某个子包中的模块
1 | import Demo.Ada.a1 |
使用 from <A> import <B> 语句则过于灵活了,它会尝试定义 <B> = <A>.<B>。
例如导入某个子包中的模块
1 | from Demo.Ada import a1 |
可以直接使用 a1 模块。
再例如导入某个子包
1 | from Demo import Ada |
注意这里不能使用
1 | from Demo import Demo.Ada |
可以看一个结构非常简单的包:d2l,
1 | d2l |
其中的 __init__.py 内容很简单
1 | """Saved source code for "Dive into Deep Learing" (https://d2l.ai). |
剩下几个 py 文件分别对应不同后端。
创建自定义的包
下面是一个简单的 mypackage 包示例,主要结构如下
1 | projectroot/ |
两个模块文件中的内容为
1 | # src/mypackage/module1.py |
通常在包的__init__.py中导入模块中的标识符,使得使用者只需要导入包,无需考虑包中的各个模块细节
1 | # src/mypackage/__init__.py |
说明:
- 这里使用了特殊的相对导入命令,这个命令只在包中有效;
__all__并不是必要的,因为默认情况下__all__就包括所有不以下划线开头的全局标识符。
使用例如
1 | from mypackage import add, mul |
为了支持 pip 安装,我们还需要提供包的信息,早期的方式为提供 setup.py,目前推荐使用 pyproject.toml,放在项目根目录下,例如
1 | [project] |
此时,这个包就可以支持 pip 安装,例如在包根目录下执行(最好在venv虚拟环境中进行)
1 | pip install . |
加上-e选项则是在开发模式下安装,pip 不会直接复制包的源码,而是建立一个引用关系(效果类似于软连接,但是与平台相关),使得在开发中对源码进行修改后不需要再次安装(但是如果升级或者进行大的变化,还是最好重新执行安装)
1 | pip install -e . |
安装过程中会在生成 mypackage 的同级目录下生成 mypackage.egg-info/ 和 build/ 两个临时文件夹,存放包元数据和编程生成的中间过程文件。
如果需要把脚本直接作为可执行文件,可以参考下面的做法(给 src/mypackage/xxx.py 提供一个可执行文件 xxx 作为命令行调用入口)
1 | [project.scripts] |
对于一个长期维护的库,通常需要考虑版本号的变化,版本号至少需要在两个位置出现:
- 作为库的版本信息:
pyproject.toml中的version字段; - 作为包的元数据信息(
mypackage/__init__.py中的__version__,用于 cli 工具的--version选项)
两者最好保持一致。除了手动同步修改,一种常见的自动化做法是在 __init__.py 中自动从 pyproject.toml 中读取版本信息
1 | from importlib.metadata import version, PackageNotFoundError |
上面的构建选择 setuptools 作为 pyproject.toml 中的 build backend,这是最传统的选择,却不是唯一的选择,例如还可以使用 Hatchling、Flit、PDM 等作为现代替代。
补充
在默认情况下,一个 ipynb 文件具有复杂格式,并不能像一个 py 脚本一样被直接导入和使用,可以通过安装 import-ipynb 这个工具来临时支持对 ipynb 文件的导入
1 | pip install import-ipynb |
当然,并不建议将 ipynb 文件作为模块使用。
