现代化python包构建流程
当你需要频繁使用一些python函数、类,就是时候把它封装为一个python包了。封包的好处有很多,包括:
- 可以结构化地将代码、文档组合,避免看不懂三个月前自己写的代码
- 可以进行版本控制、自动化测试,避免开发中的疏漏
- 方便地与他人共享代码
绝大多数的 python 包都会选择发布在 PyPI,使其可以被 Pip 直接安装,本文也是如此
开发之前,要先检查 PyPI 上是否已有同名的包,再继续如下部分
初始化
开发包需要专用虚拟环境,包配置文件,合理的项目结构,依赖项管理,以及与 Pypi 便捷的交互。pdm 很好地完成了这些工作
我们首先初始化项目,并用 git 进行版本控制
pdm init
git init
此时,包的初始结构已被建立
├── .gitignore
├── .venv
├── README.md
├── pyproject.toml
├── src
│ └── mypkg
│ └── __init__.py
└── tests
这是目前更推荐的 mypkg/src/mypkg/...
结构,相比于 mypkg/...
的平铺结构有很多好处
pyproject.toml
是包配置文件,记录包的元数据、依赖性,以及开发工具的配置
整个 src/mypkg
是包(package)的本体,其内部创建 module1.py, module2.py
等文件,称为包的一个个模块(module)。代码就写在不同的模块中
.venv
下是 pdm 创建的虚拟环境,tests
内可放置测试代码
__init__.py
文件标记了当前目录是一个包(package),内部可以留空,也可以写导入包时自动执行的代码
.gitignore
记录不需要被 git 托管的文件,在构建 python 包时,这些文件也会被 pdm 忽略
使用 pdm add
为 python 包添加依赖项
pdm add pandas
编写代码
在 terminal 进入项目目录,使用 code .
打开 VS code,这样的好处是可以自动激活默认的 pdm 虚拟环境
基本规则
编写代码时,可用 VS code 的 Ruff
插件做格式化(formatting)和代码检查(linting),indent-rainbow
可视化缩进,Even Better TOML
高亮 pyproject.toml
变量的命名要遵循统一标准,整个包要么都下划线法、要么都驼峰法。如果你追求绝对的 Pythonic,可参考 PEP8代码风格指南。环境变量是特例,一般全大写,下划线做语义划分
如果在包内,你需要几个全局可访问的变量,他们的值会随着调用不同函数而改变,比定义全局字典更合适的方式是创建一个类
例如在模块 argdict.py
中
class ArgDict():
def __init__(self):
self.arg1 = 'arg1'
self.arg2 = 'arg2'
在其他模块调用时,令函数返回实例
from .argdict import ArgDict
def foo():
argdict = ArgDict()
...
return argdict
.argdict
的 .
代表同级模块的相对路径。..
代表上级路径,...
上上级,以此类推
用户在使用时,自己维护 argdict
对象即可。这也允许多个 ArgDict
的实例同时存在
当需要提交代码到远程仓库时,务必检查是否有预期外的文件被 git 托管,尤其是密码明文、私钥、公网 ip 等敏感信息
git ls-files
优化导入
默认情况下,module 内定义的对象不会暴露到根命名空间,所以用户使用时,对 module 函数的调用需要两级
import mypkg
mypkg.module1.foo()
mypkg.module2.bar()
但我们可以在 __init__.py
中导出 module 内的对象
from .module1 import foo
from .module2 import bar
__all__ = ["foo", "bar"]
这时用户就可以在根命名空间访问了
import mypkg
mypkg.foo()
__init__.py
内的 __all__
列表还决定了,在用户使用 from mypkg import *
时,有哪些对象会被导入
交互式环境
在 VS code 写 python 包时,python debugger 是完全可用的,你可以随意加断点、按行执行
此外,你也可以将虚拟环境添加为 jupyter 内核,先在 jupyter lab 中交互式地编写代码,再回到 VS code 中做封装
# 添加内核
pdm add -d ipykernel
python -m ipykernel install --user --name=mypkg_venv
这样的 jupyter 内核,可直接 import mypkg
导出包,也能即时感知 python 包的变化
错误处理(Exception Handling)
封装为包时,代码的稳健性更加重要。除了常用的 try/except
错误处理机制外,stamina 允许捕获错误后方便地重试,这在磁盘 IO、HTTP 请求等场景下非常好用
import stamina
import httpx
@stamina.retry(on=httpx.HTTPError, attempts=3)
def httpGet(url):
client = httpx.Client(timeout=10)
res = client.get(url, follow_redirects=True)
return res
类型注解(Type Hints)
为参数添加类型注解是很好的习惯,VS code 的 mypy
插件可以根据注解做更多的检查,减少代码错误。此外,用户也能通过 help()
得到参数的类型信息
形参以 : type
的方式注解,返回值用 -> type
注解
def foo(x: int = 1, y: float, z: str, l: list, d: dict) -> float:
如果允许多种类型
# python 3.5及以上
from typing import Union
def foo(x: Union(bool, None) = None):
# python 3.10及以上
def foo(x: bool | None = None):
如果需要对列表元素也做注解,常见两种方式
# python 3.5及以上
from typing import List
def foo(x: List[str]):
# python 3.9及以上
def foo(x: list[str]):
更多场景可参考这里
配置
需要存储包的配置信息,在整个包内共享时,可使用专用的配置类,将配置项定义为类属性,并在其他模块导入
# config.py
class Settings(BaseSettings):
opt1 = 'A'
opt2 = True
settings = Settings()
# module1.py
from config import settings
如果你需要同时维持多套配置,例如网络请求相关的包,保存不同会话下的配置,就需要将配置项定义为实例属性
# config.py
class Settings(BaseSettings):
def __init__():
opt1 = 'A'
opt2 = True
# login.py
from config import Settings
def login():
settings = Settings()
...
return settings
如果你的包需要用户提供账号、密码,应该从环境变量获取,而不是让用户在代码中明文输入。可以让用户把账号密码写入工作目录 .env
文件中,用 python-dotenv
读取为环境变量
# __init__.py
from dotenv import load_dotenv
load_dotenv()
# login.py
import os
username = os.environ.get('MY_APP_USERNAME')
password = os.environ.get('MY_APP_PASSWORD')
用户使用时,需要自己在工作目录新建 .env
文件,写入
MY_APP_USERNAME='your_username'
MY_APP_PASSWORD='your_password'
在任何时候都不应该保存用户的密码
日志
相比 print
打印,使用专门的日志模块有自定义输出格式、调整日志输出级别等众多优势,在此简要介绍
使用标准库 logging 就可满足大多日志需求。你需要先创建一个 logger 对象,然后用五个函数记录日志,分别对应DEBUG、INFO、WARNING、ERROR、CRITICAL 五个等级。等级越高,代表事态越严重
import logging
logger = logging.getLogger()
logger.debug('debug log')
logger.info('info log')
logger.warning('warning log')
logger.error('error log')
logger.critical('critical')
默认情况下,logger 的等级为 WARNING,这意味着大于等于 WARNING 的日志会被记录。可通过 logger.setLevel(logging.DEBUG)
记录所有日志
所有记录的日志会被 handler 处理,可以调整输出格式、输出目标(控制台、文件等)
logger 之间可以层级关联,因此在 python 包中,最佳实践是每个 module 都通过 __name__
变量创建自己的 logger,将记录等级设为 DEBUG
,然后绑定空 handler 避免输出。这是因为用户可能有不同的日志等级、日志格式需求,不应该在包内定义死
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.NullHandler())
然后,我们在 __init__.py
定义 log()
函数,允许用户主动启用日志,也允许自定义等级、格式。函数体内用 __package__
变量创建 root logger,它会收集所有模块的日志。
def log(level=logging.DEBUG):
logger = logging.getLogger(__package__)
handler = logging.StreamHandler()
handler.setLevel(level)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return None
在用户使用包时,调用预定义的函数,就能方便地输出日志
import mypkg
mypkg.log()
mypkg.func()
编写测试
临时的手写测试
临时的手动运行测试,可以写在任何目录,我习惯写在 tests/temp_test
目录下,并将其加入 .gitignore
和 pyproject.toml
。
[tool.pdm.build]
excludes = ["tests/temp/"]
在 temp_test/xxx.py
中,可以正常导入 mypkg
,就像是在导入一个已经发布、被安装到本地的包一样
在 VS code 执行 temp_test/xxx.py
时,VS code 的工作目录是项目根目录,即与 pyproject.toml
同级
运行手写测试时,可以使用 VS code 的 debug
注意将测试代码写在以下结构内部,这可以避免其他工具意外地自动执行该代码(如 mkdocs
的插件 mkdocstrings
)
if __name__ == "__main__":
...
main()
Pytest
pytest 是最流行的 python 自动化测试包。将其添加为开发依赖
pdm add -d pytest
由于包采用了 src
结构,还需要在 pyproject.toml
中,将 src
目录加入 pythonpath
[tool.pytest.ini_options]
pythonpath = ["src"]
在 tests
目录下编写测试。pytest
会自动检测以 test_
开头或以 _test
结尾的文件或函数,将它们视作待测试的项目
from mypkg import mod
def test_foo():
assert mod.foo() == True, "Should be True"
写完测试后,在项目目录使用 pytest
开启测试。如果需要显示每个测试项,使用 pytest -v
pytest 执行时,工作目录是项目根目录,即与 pyproject.toml
同级
在 VS code 安装 Python Test Explorer for Visual Studio Code
插件,这会自动安装 Test Explorer UI
。在插件设置中开启 pytest测试
此时 VS code 会显示所有测试项,一键运行
捕获警告与错误
如果要验证代码的警告、错误,可以放在 with
上下文中,检测 Warning, Exception
通用类型
with pytest.warns(Warning):
assert translate('ACTCA') == 'T'
with pytest.raises(Exception):
translate('XXX')
如果希望测试具体的类型,可以将其替换
from Bio import BiopythonWarning
from Bio.Data.CodonTable import TranslationError
with pytest.warns(BiopythonWarning):
assert translate('ACTCA') == 'T'
with pytest.raises(TranslationError):
translate('XXX')
复杂测试
如果你的返回值更加复杂,比如是 dataframe
,除了利用 pandas
内置的测试函数 以外,更好的选择是使用快照测试
快照测试会在首次执行测试时,将输出保存到快照文件。第二次执行,则会将输出与快照文件对比,如果相同则测试通过,不同则报错
pdm add -d snapshottest
安装 snapshottest 包,后,以这样的格式写快照测试,快照会默认保存在 tests/snapshots/
下
def test_foo(snapshot):
...
snapshot.assert_match(res)
snapshottest
与 pytest
的集成很好,执行 pytest
时会自动运行快照测试
但如果你的 dataframe
稍大一些,可能就只有前几行、前几列被保存到快照里。提前调整 pandas
的显示设置即可
import pandas as pd
pd.set_option('display.max_rows', None)
pd.set_option('display.max_columns', None)
def test_foo(snapshot):
...
snapshot.assert_match(res)
如果代码修改,快照发生预期内的更新,可手动更新快照
pytest --snapshot-update
更新版本
pyproject.toml
中 version
字段标记了当前包版本,每次手动修改版本号很麻烦,可以简化
# 安装插件
pdm self add pdm-bump
pdm bump micro # creates 0.1.1 from 0.1.0
pdm bump minor # creates 0.2.0 from 0.1.0
pdm bump major # creates 2.0.0 from 0.1.0
文档
Docstring
docstring 是 python 的特性,在函数或类定义后的下一行,使用 """
多行注释写下函数或类的文档。IDE 中,这些文档就可以在调用处显示
docstring 也有一些约定俗称的风格,如果你开发的包不算很复杂,用最简单的 Google 风格就行
可以借助 Copilot 或其他 AI 插件,自动生成 docstring
如果需要手动写,可安装 autoDocstring 插件,在函数或类定义的下一行输入 """
,即可自动填充为一个 docstring 模版
此时函数文档已经可以如此访问
import mypkg
mypkg.foo.__doc__
help(mypkg.foo)
构建文档网站
mkdocstrings 可以将分散在代码中的 docstring 收集起来,再借助 mkdocs 生成 HTML 文档。也就是说,可以自动搭建起一个文档网站
我们首先安装开发依赖,mkdocstrings[python]
是适配了 python 的 mkdocstrings
,mkdocs-material
是集成了流行主题 material
的 mkdocs
pdm add -d mkdocstrings[python] mkdocs-material
使用 mkdocs new .
,这会创建以下结构
├─ docs/
│ └─ index.md
└─ mkdocs.yml
index.md
是文档网站的首页,在 docs
目录下的所有 .md
文件都会被渲染为HTML
可以通过 mkdocs.yml
配置文档网站,在这里查找配置项。以下是一个简单 python 包的常用配置
site_name: mypkg
site_description: "A test python package"
site_url: "https://william-swl.github.io/mypkg"
repo_url: "https://github.com/william-swl/mypkg"
repo_name: "william-swl/mypkg"
plugins:
- search
- mkdocstrings:
handlers:
python:
options:
show_root_heading: false
show_root_toc_entry: false
nav:
- Readme: index.md
- Reference: reference.md
theme:
name: material
我为网站配置了两个页面,Readme
页面渲染自 docs/index.md
,Reference
页面渲染自 docs/reference.md
在 docs/reference.md
中,可以使用 ::: mypkg.module1
的形式,将 src/mypkg/module1.py
中的所有对象,以及它们的 docstring提取出来
# mypkg
::: mypkg.module1
::: mypkg.module2
index.md
作为项目的 README 来写即可
启动本地文档网站,命令行会显示网址,一般是 http://localhost:8000/mypkg
mkdocs serve
由于重新渲染由 .md
文件修改触发,:::
引用的部分不是即时更新
此处插个题外话。Github 仓库页面会默认显示 README.md
为自述文件,但其实 .github/README.md
的优先级更高。所以如果需要显示 docs/index.md
的内容,可以创建符号链接 .github/README.md
,指向 docs/index.md
mkdir .github
cd .github
ln -s ../docs/index.md README.md
pyproject.toml
中,也需要指定 readme 位置
readme = "docs/index.md"
部署网站
如果你已经把本地仓库关联到远程 github 仓库,就可以一键将文档网站部署到 Github Pages
,使文档网站可以被公开访问。运行
mkdocs gh-deploy --clean
该命令将清理 site
目录(mkdocs
构建默认输出目录),把 .md
文档构建为 HTML,在本地和 github 仓库同时创建 gh-pages
分支并提交 HTML,最后部署到 https://username.github.io/mypkg
由于部署需要时间,可能稍等一会儿网站才能访问
这时,你可以在 pyproject.toml
添加包的文档地址和 Github 地址
[project.urls]
Docs = "https://william-swl.github.io/gisflu"
Github = "https://github.com/william-swl/gisflu"
发布到 Pypi
前往 PyPI 注册账号,在 Account settings 下新建 API token,名称可以随便起
然后安装 keyring,以在本地存储 pypi 的账号密码。此处不能输入登录信息,账号是 __token__
,密码是上一步创建的 api token,详细说明可参考这里
pdm self add keyring
pdm config repository.pypi.username __token__
pdm config repository.pypi.password <api-token>
运行 pdm build
,源码包和 wheel 包会构建在 dist/
目录下,可检查构建是否符合预期。两者的区别见 pdm backend文档
如果没有问题,pdm publish
即可发布在 Pypi
其他
Pdm 快捷命令
在 pyproject.toml
中,可以定义快捷命令,然后以 pdm run ...
的方式调用
[tool.pdm.scripts]
docServe = "mkdocs serve"
docPage = "mkdocs gh-deploy --clean"
Nox 自动化
Nox 可以将许多命令一次性完成,例如测试、更新版本、构建文档、发布
pdm add -d nox
使用 noxfile.py
配置文件,一个典型场景是
import os
import nox
os.environ.update({"PDM_IGNORE_SAVED_PYTHON": "1"})
@nox.session()
def tests(session):
session.run("pytest")
包统计
PyPI stats 统计了 python 包的下载次数
结语
以上,我们讨论了一个现代化的 python 包构建流程。这里是一些其他的好文章,玩得开心!
Python Packaging User Guide
Python Packaging Best Practices
Modern Good Practices for Python Development
Structuring a Python Project with PDM