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 简介

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

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

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

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

包格式

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

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

以下是 Wheel 和 Egg 的主要区别:

  • 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_insall 是 setuptool 包提供的第三方包安装工具,而 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