Python 库打包分发(setup.py 编写)简易指南

转载 http://blog.konghy.cn/2018/04/29/setup-dot-py/

Python 有非常丰富的第三方库可以使用,很多开发者会向 pypi 上提交自己的 Python 包。要想向 pypi 包仓库提交自己开发的包,首先要将自己的代码打包,才能上传分发。

distutils 简介

distutils 是标准库中负责建立 Python 第三方库的安装器,使用它能够进行 Python 模块的安装和发布。distutils 对于简单的分发很有用,但功能缺少。大部分 Python 用户会使用更先进的 setuptools 模块.

setuptools 简介

setuptoolsdistutils 增强版,不包括在标准库中。其扩展了很多功能,能够帮助开发者更好的创建和分发 Python 包。大部分 Python 用户都会使用更先进的 setuptools 模块。

Setuptools 有一个 fork 分支是 distribute。它们共享相同的命名空间,因此如果安装了 distributeimport setuptools 时实际上将导入使用 distribute 创建的包。Distribute 已经合并回 setuptools

还有一个大包分发工具是 distutils2 ,其试图尝试充分利用 distutilsdetuptoolsdistribute 并成为 Python 标准库中的标准工具。但该计划并没有达到预期的目的,且已经是一个废弃的项目。

因此,setuptools 是一个优秀的,可靠的 Pthon 包安装与分发工具。以下设计到包的安装与分发均针对 setuptools,并不保证 distutils 可用。

包格式

Python 库打包的格式包括 WheelEggEgg 格式是由 setuptools 在 2004 年引入,而 Wheel 格式是由 PEP427 在 2012 年定义。使用 WheelEgg 安装都不需要重新构建和编译,其在发布之前就应该完成测试和构建。

EggWheel 本质上都是一个 zip 格式包,Egg 文件使用 .egg 扩展名,Wheel 使用 .whl 扩展名。Wheel 的出现是为了替代 Egg,其现在被认为是 Python 的二进制包的标准格式。

以下是 WheelEgg 的主要区别:

  • Wheel 有一个官方的 PEP427 来定义,而 Egg 没有 PEP 定义
  • Wheel 是一种分发格式,即打包格式。而 Egg 既是一种分发格式,也是一种运行时安装的格式,并且是可以被直接 import
  • Wheel 文件不会包含 .pyc 文件
  • Wheel 使用和 PEP376 兼容的 .dist-info 目录,而 Egg 使用 .egg-info 目录
  • Wheel 有着更丰富的命名规则。
  • Wheel 是有版本的。每个 Wheel 文件都包含 wheel 规范的版本和打包的实现
  • Wheel 在内部被 sysconfig path type 管理,因此转向其他格式也更容易

详细描述可见:Wheel vs Egg

setup.py 文件

Python 库打包分发的关键在于编写 setup.py 文件。setup.py 文件编写的规则是从 setuptools 或者 distuils 模块导入 setup 函数,并传入各类参数进行调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# coding:utf-8

from setuptools import setup
# or
# from distutils.core import setup

setup(
name='demo', # 包名字
version='1.0', # 包版本
description='This is a test of the setup', # 简单描述
author='huoty', # 作者
author_email='sudohuoty@163.com', # 作者邮箱
url='https://www.konghy.com', # 包的主页
packages=['demo'], # 包
)

参数概述

setup 函数常用的参数如下:

参数 说明
name 包名称
version 包版本
author 程序的作者
author_email 程序的作者的邮箱地址
maintainer 维护者
maintainer_email 维护者的邮箱地址
url 程序的官网地址
license 程序的授权信息
description 程序的简单描述
long_description 程序的详细描述
platforms 程序适用的软件平台列表
classifiers 程序的所属分类列表
keywords 程序的关键字列表
packages 需要处理的包目录(通常为包含 __init__.py 的文件夹)
py_modules 需要打包的 Python 单文件列表
download_url 程序的下载地址
cmdclass 添加自定义命令
package_data 指定包内需要包含的数据文件
include_package_data 自动包含包内所有受版本控制(cvs/svn/git)的数据文件
exclude_package_data 当 include_package_data 为 True 时该选项用于排除部分文件
data_files 打包时需要打包的数据文件,如图片,配置文件等
ext_modules 指定扩展模块
scripts 指定可执行脚本,安装时脚本会被安装到系统 PATH 路径下
package_dir 指定哪些目录下的文件被映射到哪个源码包
requires 指定依赖的其他包
provides 指定可以为哪些模块提供依赖
install_requires 安装时需要安装的依赖包
entry_points 动态发现服务和插件,下面详细讲
setup_requires 指定运行 setup.py 文件本身所依赖的包
dependency_links 指定依赖包的下载地址
extras_require 当前包的高级/额外特性需要依赖的分发包
zip_safe 不压缩包,而是以目录的形式安装

更多参数可见:https://setuptools.readthedocs.io/en/latest/setuptools.html

find_packages

对于简单工程来说,手动增加 packages 参数是容易。而对于复杂的工程来说,可能添加很多的包,这是手动添加就变得麻烦。Setuptools 模块提供了一个 find_packages 函数,它默认在与 setup.py 文件同一目录下搜索各个含有 __init__.py 的目录做为要添加的包。

1
find_packages(where='.', exclude=(), include=('*',))

find_packages 函数的第一个参数用于指定在哪个目录下搜索包,参数 exclude 用于指定排除哪些包,参数 include 指出要包含的包。

默认默认情况下 setup.py 文件只在其所在的目录下搜索包。如果不用 find_packages ,想要找到其他目录下的包,也可以设置 package_dir 参数,其指定哪些目录下的文件被映射到哪个源码包,如: package_dir={'': 'src'} 表示 root package 中的模块都在 src 目录中。

包含数据文件

  • package_data:

该参数是一个从包名称到 glob 模式列表的字典。如果数据文件包含在包的子目录中,则 glob 可以包括子目录名称。其格式一般为 {'package_name': ['files']} ,比如: package_data={'mypkg': ['data/*.dat'],}

  • include_package_data:

该参数被设置为 True 时自动添加包中受版本控制的数据文件,可替代 package_data,同时, exclude_package_data 可以排除某些文件。注意当需要加入没有被版本控制的文件时,还是仍然需要使用 package_data 参数才行。

  • data_files:

该参数通常用于包含不在包内的数据文件,即包的外部文件,如:配置文件,消息目录,数据文件。其指定了一系列二元组,即(目的安装目录,源文件) ,表示哪些文件被安装到哪些目录中。如果目录名是相对路径,则相对于安装前缀进行解释。

  • manifest template:

manifest template 即编写 MANIFEST.in 文件,文件内容就是需要包含在分发包中的文件。一个 MANIFEST.in 文件如下:

1
2
3
include *.txt
recursive-include examples *.txt *.py
prune examples/sample?/build

MANIFEST.in 文件的编写规则可参考:https://docs.python.org/3.6/distutils/sourcedist.html

生成脚本

有两个参数 scripts 参数或 console_scripts 可用于生成脚本。

entry_points 参数用来支持自动生成脚本,其值应该为是一个字典,从 entry_point 组名映射到一个表示 entry_point 的字符串或字符串列表,如:

1
2
3
4
5
6
7
8
9
setup(
# other arguments here...
entry_points={
'console_scripts': [
'foo=foo.entry:main',
'bar=foo.entry:main',
],
}
)

scripts 参数是一个 list,安装包时在该参数中列出的文件会被安装到系统 PATH 路径下。如:

1
scripts=['bin/foo.sh', 'bar.py']

用如下方法可以将脚本重命名,例如去掉脚本文件的扩展名(.py.sh):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from setuptools.command.install_scripts import install_scripts

class InstallScripts(install_scripts):

def run(self):
setuptools.command.install_scripts.install_scripts.run(self)

# Rename some script files
for script in self.get_outputs():
if basename.endswith(".py") or basename.endswith(".sh"):
dest = script[:-3]
else:
continue
print("moving %s to %s" % (script, dest))
shutil.move(script, dest)

setup(
# other arguments here...
cmdclass={
"install_scripts": InstallScripts
}
)

其中,cmdclass 参数表示自定制命令,后文详述。

ext_modules

ext_modules 参数用于构建 C 和 C++ 扩展扩展包。其是 Extension 实例的列表,每一个 Extension 实例描述了一个独立的扩展模块,扩展模块可以设置扩展包名,头文件、源文件、链接库及其路径、宏定义和编辑参数等。如:

1
2
3
4
5
6
7
8
9
setup(
# other arguments here...
ext_modules=[
Extension('foo',
glob(path.join(here, 'src', '*.c')),
libraries = [ 'rt' ],
include_dirs=[numpy.get_include()])
]
)

详细了解可参考:https://docs.python.org/3.6/distutils/setupscript.html#preprocessor-options

zip_safe

zip_safe 参数决定包是否作为一个 zip 压缩后的 egg 文件安装,还是作为一个以 .egg 结尾的目录安装。因为有些工具不支持 zip 压缩文件,而且压缩后的包也不方便调试,所以建议将其设为 False,即 zip_safe=False

自定义命令

Setup.py 文件有很多内置的的命令,可以使用 python setup.py --help-commands 查看。如果想要定制自己需要的命令,可以添加 cmdclass 参数,其值为一个 dict。实现自定义命名需要继承 setuptools.Command 或者 distutils.core.Command 并重写 run 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from setuptools import setup, Command

class InstallCommand(Command):
description = "Installs the foo."
user_options = [
('foo=', None, 'Specify the foo to bar.'),
]
def initialize_options(self):
self.foo = None
def finalize_options(self):
assert self.foo in (None, 'myFoo', 'myFoo2'), 'Invalid foo!'
def run(self):
install_all_the_things()

setup(
...,
cmdclass={
'install': InstallCommand,
}
)

依赖关系

如果包依赖其他的包,可以指定 install_requires 参数,其值为一个 list,如:

1
2
3
4
install_requires=[
'requests>=1.0',
'flask>=1.0'
]

指定该参数后,在安装包时会自定从 pypi 仓库中下载指定的依赖包安装。

此外,还支持从指定链接下载依赖,即指定 dependency_links 参数,如:

1
2
3
4
dependency_links = [
"http://packages.example.com/snapshots/foo-1.0.tar.gz",
"http://example2.com/p/bar-1.0.tar.gz",
]

分类信息

classifiers 参数说明包的分类信息。所有支持的分类列表见:https://pypi.org/pypi?%3Aaction=list_classifiers

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
classifiers = [
# 发展时期,常见的如下
# 3 - Alpha
# 4 - Beta
# 5 - Production/Stable
'Development Status :: 3 - Alpha',

# 开发的目标用户
'Intended Audience :: Developers',

# 属于什么类型
'Topic :: Software Development :: Build Tools',

# 许可证信息
'License :: OSI Approved :: MIT License',

# 目标 Python 版本
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
]

setup.py 命令

setup.py 文件有很多内置命令可供使用,查看所有支持的命令:

1
python setup.py --help-commands

此处列举一些常用命令:

  • build:

构建安装时所需的所有内容

  • sdist:

构建源码分发包,在 Windows 下为 zip 格式,Linux 下为 tag.gz 格式 。执行 sdist 命令时,默认会被打包的文件:

1
2
3
4
5
所有 py_modules 或 packages 指定的源码文件
所有 ext_modules 指定的文件
所有 package_data 或 data_files 指定的文件
所有 scripts 指定的脚本文件
README、README.txt、setup.py 和 setup.cfg文件

该命令构建的包主要用于发布,例如上传到 pypi 上。

  • bdist:

构建一个二进制的分发包。

  • bdist_egg:

构建一个 egg 分发包,经常用来替代基于 bdist 生成的模式

  • install:

安装包到系统环境中。

  • develop:

以开发方式安装包,该命名不会真正的安装包,而是在系统环境中创建一个软链接指向包实际所在目录。这边在修改包之后不用再安装就能生效,便于调试。

  • register、upload:

用于包的上传发布,后文详述。

setup.cfg 文件

setup.cfg 文件用于提供 setup.py 的默认参数,详细的书写规则可参考:https://docs.python.org/3/distutils/configfile.html

版本命名

包版本的命名格式应为如下形式:

1
N.N[.N]+[{a|b|c|rc}N[.N]+][.postN][.devN]

从左向右做一个简单的解释:

  • N.N: 必须的部分,两个 “N” 分别代表了主版本和副版本号
  • [.N]: 次要版本号,可以有零或多个
  • {a|b|c|rc}: 阶段代号,可选, a, b, c, rc 分别代表 alpha, beta, candidate 和 release candidate
  • N[.N]: 阶段版本号,如果提供,则至少有一位主版本号,后面可以加无限多位的副版本号
  • .postN: 发行后更新版本号,可选
  • .devN: 开发期间的发行版本号,可选

easy_install 与 pip

easy_insallsetuptool 包提供的第三方包安装工具,而 pip 是 Python 中一个功能完备的包管理工具,是 easy_install 的改进版,提供更好的提示信息,删除包等功能。

pip 相对于 easy_install 进行了以下几个方面的改进:

  • 所有的包是在安装之前就下载了,所以不可能出现只安装了一部分的情况
  • 在终端上的输出更加友好
  • 对于动作的原因进行持续的跟踪。例如,如果一个包正在安装,那么 pip 就会跟踪为什么这个包会被安装
  • 错误信息会非常有用
  • 代码简洁精悍可以很好的编程
  • 不必作为 egg 存档,能扁平化安装(仍然保存 egg 元数据)
  • 原生的支持其他版本控制系统(Git, Mercurial and Bazaar)
  • 加入卸载包功能
  • 可以简单的定义修改一系列的安装依赖,还可以可靠的赋值一系列依赖包

发布包

PyPI(Python Package Index) 是 Python 官方维护的第三方包仓库,用于统一存储和管理开发者发布的 Python 包。

如果要发布自己的包,需要先到 pypi 上注册账号。然后创建 ~/.pypirc 文件,此文件中配置 PyPI 访问地址和账号。如的.pypirc 文件内容请根据自己的账号来修改。

典型的 .pypirc 文件

1
2
3
4
5
6
[distutils]
index-servers = pypi

[pypi]
username:xxx
password:xxx

接着注册项目:

python setup.py register

该命令在 PyPi 上注册项目信息,成功注册之后,可以在 PyPi 上看到项目信息。最后构建源码包发布即可:

python setup.py sdist upload

Python Packages

Python Packages 是一本开源书籍,描述了用于创建 Python 包的现代高效工作流程。

在开始前首先安装下列包:

  • pipx:在隔离环境中安装和运行 Python 应用程序。
  • poetry :将帮助我们构建自己的 Python 包的软件。
  • cookiecutter :将帮助我们从预制模板创建包的软件。

如何实现一个 python 包

初始化包

我们将创建一个示例包 pycounts 帮助我们计算文本文件中的字数,使用 cookiecutter 工具为我们创建包结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ cookiecutter https://github.com/py-pkgs/py-pkgs-cookiecutter.git
author_name [Monty Python]: ckcat
package_name [mypkg]: pycounts
package_short_description [A package for doing great things!]: Calculate word counts in a text
package_version [0.1.0]:
python_version [3.9]:
Select open_source_license:
1 - MIT
2 - Apache License 2.0
3 - GNU General Public License v3.0
4 - CC0 v1.0 Universal
5 - BSD 3-Clause
6 - Proprietary
7 - None
Choose from 1, 2, 3, 4, 5, 6, 7 [1]:
Select include_github_actions:
1 - no
2 - ci
3 - ci+cd
Choose from 1, 2, 3 [1]:

根据提示操作完成后,会在当前目录下创建一个名为 pycounts 的新目录,其中包含适合构建功能齐全的 Python 包的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pycounts
├── .readthedocs.yml ┐
├── CHANGELOG.md │
├── CONDUCT.md │
├── CONTRIBUTING.md │
├── docs │
│ ├── changelog.md │
│ ├── conduct.md │
│ ├── conf.py │
│ ├── contributing.md │ Package documentation
│ ├── example.ipynb │
│ ├── index.md │
│ ├── make.bat │
│ ├── Makefile │
│ └── requirements.txt │
├── LICENSE │
├── README.md ┘
├── pyproject.toml ┐
├── src │
│ └── pycounts │ Package source code, metadata,
│ ├── __init__.py │ and build instructions
│ └── pycounts.py ┘
└── tests ┐
└── test_pycounts.py ┘ Package tests

接着将我们的包设置本地版本控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cd pycounts\
$ git init
Initialized empty Git repository in .../python/pycounts/.git/
$ git add .
$ git commit -m"initial package setup"
[master (root-commit) 453ffcc] initial package setup
20 files changed, 504 insertions(+)
create mode 100644 .gitignore
create mode 100644 .readthedocs.yml
create mode 100644 CHANGELOG.md
...
create mode 100644 src/pycounts/__init__.py
create mode 100644 src/pycounts/pycounts.py
create mode 100644 tests/test_pycounts.py

实现包的功能

修改 src/pycounts/pycounts.py 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from collections import Counter
from string import punctuation


def load_text(input_file):
"""Load text from a text file and return as a string."""
with open(input_file, "r") as file:
text = file.read()
return text

def clean_text(text):
"""Lowercase and remove punctuation from a string."""
text = text.lower()
for p in punctuation:
text = text.replace(p, "")
return text

def count_words(input_file):
"""Count unique words in a string."""
text = load_text(input_file)
text = clean_text(text)
words = text.split()
return Counter(words)

下面我们将使用 poetry 对包进行管理,pyproject.toml 文件存储包的所有元数据和安装说明。内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[tool.poetry]
name = "pycounts"
version = "0.1.0"
description = "Calculate word counts in a text"
authors = ["ckcat"]
license = "MIT"
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.9"

[tool.poetry.dev-dependencies]

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

[tool.poetry] 定义包元数据。包的 name 、 version 、 description 和 authors 是必需的。
[tool.poetry.dependencies] 标识包的依赖项。
[tool.poetry.dev-dependencies] 标识包的开发依赖项
[build-system] 标识构建包所需的构建工具。

可以使用命令 poetry install 安装我们的包:

1
2
3
4
5
6
7
8
9
 $ poetry.exe install
Creating virtualenv pycounts-SDM4jxal-py3.9 in
...\AppData\Local\pypoetry\Cache\virtualenvsvirtualenvs
Updating dependencies
Resolving dependencies... (0.1s)

Writing lock file

Installing the current project: pycounts (0.1.0)

当运行 poetry install 时, poetry 会创建一个 poetry.lock 文件,其中包含您在开发包时安装的所有依赖项的记录。

接下来添加版本控制。

1
2
3
4
$ git add src\pycounts\pycounts.py
$ git commit -m"feat: add word counting functions"
[master 510913a] feat: add word counting functions
1 file changed, 23 insertions(+)

其中对 Git 提交消息使用 Angular 风格。

  • build:表示对构建系统或外部依赖项的更改。
  • docs:表示对文档的更改。
  • feat:表示添加到代码库的新功能。
  • fix: indicates a bug fix. “fix”:表示错误修复。
  • test:表示测试框架的变化。

可以使用命令 poetry add 将依赖项添加到包里。

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
base) $ poetry add matplotlib
Using version ^3.7.1 for matplotlib

Updating dependencies
Resolving dependencies...

Resolving dependencies... (52.9s)

Writing lock file

Package operations: 13 installs, 0 updates, 0 removals

• Installing numpy (1.24.3)
• Installing six (1.16.0)
• Installing zipp (3.15.0)
• Installing contourpy (1.0.7)
• Installing cycler (0.11.0)
• Installing fonttools (4.39.3)
• Installing importlib-resources (5.12.0)
• Installing kiwisolver (1.4.4)
• Installing packaging (23.1)
• Installing pillow (9.5.0)
• Installing pyparsing (3.0.9)
• Installing python-dateutil (2.8.2)
• Installing matplotlib (3.7.1)

此命令会将指定的依赖项安装到当前虚拟环境中,并将更新 pyproject.toml 文件的 [tool.poetry.dependencies] 部分:

1
2
3
[tool.poetry.dependencies]
python = "^3.9"
matplotlib = "^3.4.3"

然后创建一个名为 src/pycounts/plotting.py 的新模块来容纳我们的绘图函数 plot_words()

1
2
3
4
5
6
7
8
9
10
11
import matplotlib.pyplot as plt

def plot_words(word_counts, n=10):
"""Plot a bar chart of word counts."""
top_n_words = word_counts.most_common(n)
word, count = zip(*top_n_words)
fig = plt.bar(range(n), count)
plt.xticks(range(n), labels=word, rotation=45)
plt.xlabel("Word")
plt.ylabel("Count")
return fig

接着添加版本控制:

1
2
3
4
5
6
7
8
9
10
$ git add src\pycounts\plotting.py
$ git commit -m"feat: add plotting module"
[master fa9bb17] feat: add plotting module
1 file changed, 11 insertions(+)
create mode 100644 src/pycounts/plotting.py
$ git add pyproject.toml poetry.lock
$ git commit -m"build: add matplotlib as a dependency"
[master 6f01657] build: add matplotlib as a dependency
2 files changed, 468 insertions(+)
create mode 100644 poetry.lock

测试

使用命令 poetry add --dev 将它添加为包的开发依赖项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ poetry add --dev pytest
The --dev option is deprecated, use the `--group dev` notation instead.
Using version ^7.3.1 for pytest

Updating dependencies
Resolving dependencies... (2.1s)

Writing lock file

Package operations: 6 installs, 0 updates, 0 removals

• Installing colorama (0.4.6)
• Installing exceptiongroup (1.1.1)
• Installing iniconfig (2.0.0)
• Installing pluggy (1.0.0)
• Installing tomli (2.0.1)
• Installing pytest (7.3.1)

pytest 将会被添加到 pyproject.toml 文件的 [tool.poetry.dev-dependencies] 部分

1
2
[tool.poetry.dev-dependencies]
pytest = "^6.2.5"

pytest 测试被编写为以 test_ 为前缀的函数,其中包含一个或多个检查某些代码功能的 assert 语句。

将单元测试作为测试函数添加到 tests/test_pycounts.py

1
2
3
4
5
6
7
8
9
10
11
from pycounts.pycounts import count_words
from collections import Counter

def test_count_words():
"""Test word counting from a file."""
expected = Counter({'insanity': 1, 'is': 1, 'doing': 1,
'the': 1, 'same': 1, 'thing': 1,
'over': 2, 'and': 2, 'expecting': 1,
'different': 1, 'results': 1})
actual = count_words("tests/einstein.txt")
assert actual == expected, "Einstein quote counted incorrectly!"

其中 einstein.txt 内容如下:

1
Insanity is doing the same thing over and over and expecting different results.

使用 pytest 来运行我们的测试

1
2
3
4
5
6
 $ poetry.exe run pytest tests\
============================================ test session starts
...
tests\test_pycounts.py . [100%]

============================================= 1 passed in 0.05s