Python 包的开发是一项重要的技能,可以帮助你组织代码、实现代码复用,并且将功能模块分发给其他开发者使用。

包和模块是什么

在 Python 中,一个包(Package)就是一个包含多个模块的目录,其中通过 __init__.py 文件来表明它是一个包。包允许你将代码逻辑分解为多个文件,并且通过模块的方式进行导入和复用。

  • 模块:一个 Python 文件(*.py 文件)就是一个模块。模块可以包含函数、类和变量。
  • 包:包是包含多个模块的文件夹。包使得模块之间可以被组织起来,以便更好的管理。

目录结构示例:

1
2
3
4
my_package/
├── __init__.py # 包初始化文件
├── module_a.py # 模块 A
├── module_b.py # 模块 B

在代码中使用包(如果要供外部使用,推荐使用相对路径):

1
2
3
4
5
# 导入包中的模块
from my_package import module_a

# 使用模块中的函数
module_a.some_function()

一个典型的 python 包的格式

1
2
3
4
5
6
7
8
9
10
11
12
my_package/
├── my_package/ # 包目录
│ ├── __init__.py # 包初始化文件
│ ├── module1.py # 包中的第一个模块
│ └── module2.py # 包中的第二个模块
├── tests/ # 测试目录
│ └── test_module1.py
├── README.md # 包的说明文件
├── setup.py # 安装脚本
├── pyproject.toml # 现代构建系统配置文件
├── LICENSE # 许可证
└── requirements.txt # 依赖文件

setup.pypyproject.toml 必须存在一个,用于说明包如何安装,后者更加先进,建议使用。

模块的初始化

__init__.py 是 Python 包的初始化模块,它负责定义包在被导入时的行为。

包的标识:没有 __init__.py 的文件夹将不会被视为 Python 包(在较早的 Python 版本中这是必须的,但在 Python 3.3 及之后不是必须的了,尽管还是一个好习惯)。

导入时行为:当你直接导入包时,例如 import my_package,只有 __init__.py 中定义的内容(函数、类、变量)可以被直接使用。要使用其他子模块或子包中的内容,需要显式导入它们,或在 __init__.py 中设置好默认的导入。当包被导入时,__init__.py 中的代码会被自动执行一遍。因此,如果有初始化逻辑(例如设置某些配置、加载资源),可以在 __init__.py 中编写,它们会在导入包时执行。但 __init__.py 不会作为脚本运行(即不会执行 __main__ 语句中的内容)。

导入的顺序深度优先算法。先确保 父包初始化,在父包的初始化过程中,按照导入顺序逐步进行深度优先的子包和子模块初始化。

开发测试

本地测试的时候,应该在包内使用相对路径,并且以“可编辑模式”安装到虚拟环境中。这会在虚拟环境或全局环境中创建一个符号链接,指向包的源代码目录,修改源代码会立即生效。

1
pip install -e .

单元测试:

1
2
3
4
5
6
7
8
9
10
11
# tests/test_module1.py
import unittest
from my_package.module1 import some_function

class TestModule1(unittest.TestCase):
def test_some_function(self):
result = some_function()
self.assertEqual(result, "expected result")

if __name__ == "__main__":
unittest.main()

还有简单的测试,文件以 _test.py 结尾,然后函数以 test_ 开头,依赖 pytest。

安装包

下面是一个传统的 pyproject 的定义,[build-system] 里这两者一般都不用变动。

  • requires 指定了构建项目所需的工具和版本。在这个例子中,你需要 setuptools 版本 >= 61.0 来进行包的构建。
  • build-backend 指定了构建后端,这里使用的是 setuptools 的构建元数据模块 setuptools.build_meta。

[project]是核心的配置参数

  • requires-python:表明 Python 版本的要求,这里要求 Python >= 3.9。
  • classifiers:这些是 Python 包的元数据,用于描述包的兼容性、用途和许可证等。
  • dependencies:列出了包的依赖项,当其他人安装此包时,这些依赖项也会被自动安装。

[tool.setuptools.packages.find] 告诉 setuptools 如何找到包。这里它会从当前目录中找到所有以 sgp 开头的包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "openzeppelin-solidity-grammar-parser"
version = "0.0.4"
authors = [{ name = "Georgii Plotnikov", email = "accembler@gmail.com" }]
description = "Solidity ANTLR4 grammar Python parser"
readme = "README.md"
requires-python = ">=3.9"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"antlr4-python3-runtime == 4.13.1",
"coverage == 7.3.1",
"simplejson == 3.19.1",
"typing == 3.7.4.3",
"typing_extensions == 4.8.0",
]

[project.urls]
"Homepage" = "https://github.com/OpenZeppelin/sgp"
"Bug Tracker" = "https://github.com/OpenZeppelin/sgp/issues"

[tool.setuptools.packages.find]
where = ["."]
include = ["sgp*"]

对于 poetry 工具,需要另外一套配置,则把元信息,运行时依赖依赖和开发时的依赖,构建系统都写的比较清楚。poetry build 的效果和 python -m build 类似,会在 dist/ 目录下生成两种类型的分发文件:

  • Source distribution (sdist):一个 .tar.gz 文件,用于源代码分发。
  • Wheel (bdist_wheel):一个 .whl 文件,这是已编译的、便于安装的包格式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[tool.poetry]
name = "openzeppelin-solidity-grammar-parser"
version = "0.0.4"
description = "Solidity ANTLR4 grammar Python parser"
authors = ["Georgii Plotnikov <accembler@gmail.com>"]
readme = "README.md"
license = "MIT"
homepage = "https://github.com/OpenZeppelin/sgp"
repository = "https://github.com/OpenZeppelin/sgp"
documentation = "https://github.com/OpenZeppelin/sgp"
keywords = ["solidity", "parser", "antlr4"]

[tool.poetry.dependencies]
python = ">=3.9"
antlr4-python3-runtime = "4.13.1"
coverage = "7.3.1"
simplejson = "3.19.1"
typing = "3.7.4.3"
typing_extensions = "4.8.0"

[tool.poetry.dev-dependencies]
black = "^23.0"
ruff = "^0.0.288"
pytest = "^7.0"

[tool.poetry.packages]
# 只包含 sgp 目录下的代码
# 这将确保打包时只会包含 sgp 目录,而不会把其他项目文件夹(如 tests/、scripts/ 等)打包进来。
include = ["sgp"]

# 这一部分用于指定额外要包含的非 Python 文件。
[tool.poetry.include]
# 包含 README.md
README.md = { path = "README.md", format = "text/markdown" }
# 包含 LICENSE
LICENSE = { path = "LICENSE", format = "text/plain" }
# 包含某些其他文件
"sgp/parser/*.tokens" = { format = "text/plain" }

[build-system]
requires = ["poetry-core>=1.1.0"]
build-backend = "poetry.core.masonry.api"

而且 poetry install 不仅会安装好依赖,还会执行了类似 pip install -e . 的功能,执行了可编辑模式安装。

代码编译分发

上一小节介绍了如何打包,并且介绍了 2 种打包生成的文件。。

1
2
3
dist/
my_package-0.1.0.tar.gz
my_package-0.1.0-py3-none-any.whl

这里两种分发包都是可以安装的 pip install dist/my_package-0.1.0-py3-none-any.whlpip install dist/my_package-0.1.0.tar.gz

安装源代码分发包时,pip 会从源代码构建包,这通常涉及到编译步骤。如果项目中有 C 扩展或其他需要编译的代码,安装时会需要构建工具(如 gcc、make 等)来编译这些部分。这种方式兼容性好。但是构建时如果出问题,需要使用者具备比较深入的知识。如果你的项目包含 C 扩展或其他需要编译的代码,源代码分发包是必不可少的。

.whl 文件是一种标准的 Python 二进制包格式,它是预编译好的包。包含编译后的文件,并且已经处理好所有依赖,因此安装时不需要再次编译。Wheel 包通常包括预编译的 C 扩展、二进制文件以及纯 Python 文件。安装速度快,不用编译,但是是平台和架构相关的,需要编译多个版本。
发布的时候,使用 twine upload dist/*