当你需要频繁使用一些python函数、类,就是时候把它封装为一个python包了。封包的好处有很多,包括:

  1. 可以结构化地将代码、文档组合,避免看不懂三个月前自己写的代码
  2. 可以进行版本控制、自动化测试,避免开发中的疏漏
  3. 方便地与他人共享代码

绝大多数的 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 就可满足大多日志需求。日志分为五个等级:DEBUG、INFO、WARNING、ERROR、CRITICAL
在每一个 module 中,通过 __name__ 变量创建名为 mypkg.thismodule 的 logger,要求记录 DEBUG 及以上等级的日志,也就是所有日志
再用 logging.NullHandler() 创建空的处理器,使得默认情况下,用户使用 mypkg 时不会输出日志。这是因为用户可能有不同的日志等级、日志格式需求,不应该在包内定义死

import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.NullHandler())

然后,我们在包内定义一个合适的 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 目录下,并将其加入 .gitignorepyproject.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测试
image.png|675
此时 VS code 会显示所有测试项,一键运行
image.png|800

如果你的返回值更加复杂,比如是 dataframe,除了利用 pandas 内置的测试函数 以外,更好的选择是使用快照测试
快照测试会在首次执行测试时,将输出保存到快照文件。第二次执行,则会将输出与快照文件对比,如果相同则测试通过,不同则报错

pdm add -d snapshottest

安装 snapshottest 包,后,以这样的格式写快照测试,快照会默认保存在 tests/snapshots/

def test_foo(snapshot):
	...
    snapshot.assert_match(res)

snapshottestpytest 的集成很好,执行 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.tomlversion 字段标记了当前包版本,每次手动修改版本号很麻烦,可以简化

# 安装插件
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
image.png|525
如果需要手动写,可安装 autoDocstring 插件,在函数或类定义的下一行输入 """,即可自动填充为一个 docstring 模版
image.png
此时函数文档已经可以如此访问

import mypkg
mypkg.foo.__doc__
help(mypkg.foo)

构建文档网站

mkdocstrings 可以将分散在代码中的 docstring 收集起来,再借助 mkdocs 生成 HTML 文档。也就是说,可以自动搭建起一个文档网站
我们首先安装开发依赖,mkdocstrings[python] 是适配了 python 的 mkdocstringsmkdocs-material 是集成了流行主题 materialmkdocs

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.mdReference 页面渲染自 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 文件修改触发,::: 引用的部分不是即时更新
image.png|900
此处插个题外话。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
image.png|975
由于部署需要时间,可能稍等一会儿网站才能访问
这时,你可以在 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,名称可以随便起
image.png
然后安装 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