目录

经典打包

科学 Python 生态系统中的库采用多种不同的打包风格,但本文档旨在概述一种推荐的风格,现有包应逐渐采用。每个决定的理由也在文中说明。

存在几个流行的打包系统。本指南涵盖 Setuptools,它是最古老的系统,支持编译扩展。如果您不是在处理遗留代码,或者愿意进行更大的更改,其他系统,如 Hatch 则要简单得多 - 本页面的大部分内容对于这些系统来说都是不需要的。即使是 setuptools 现在也支持现代配置,尽管为了支持编译包,仍然需要 setup.py。

另请参阅 Python 打包指南,特别是 Python 打包教程

原始源代码位于 Git 中,并包含一个 setup.py。您可以通过 pip 直接从 Git 安装,但通常用户会从 PyPI 上托管的发布版安装。有三种选择:A) 源代码包,称为 SDist,其名称以 .tar.gz 结尾。这是一个 GitHub 仓库的副本,去除了几个特定的内容,例如 CI 文件,并且可能包含子模块(如果有)。B) 纯 Python 轮子,其名称以 .whl 结尾;只有在库中没有编译扩展时才有可能。它包含 setup.py,而是包含一个 PKG_INFO 文件,该文件是从 setup.py(或其他构建系统)渲染的。C) 如果不是纯 Python,则是一组适用于每个二进制平台的轮子,通常每个支持的 Python 版本和操作系统各一个。

开发人员需求(A 或 Git 用户)通常高于使用 B 或 C 的需求。Poetry 和可选的 flit 创建包含 setup.py 的 SDist,所有替代打包系统都会生成“正常”的轮子。

包结构(中等优先级)

所有包应该有一个 src 文件夹,包代码位于其中,例如 src/<package>/。这可能看起来像多余的麻烦;毕竟,您可以在主目录中键入“python”并避免安装,如果您没有 src 文件夹!但是,这是一种不好的做法,会导致一些常见的错误,例如运行 pytest 并获得本地版本而不是已安装版本 - 这显然在您构建库的某些部分或访问包元数据时会容易出错。

PEP 517/518 支持(高优先级)

包应该提供一个 pyproject.toml 文件,它至少看起来像这样

[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"

如果您使用的是 Pip 10 或更高版本(您可以在特殊情况下使用 --no-build-isolation 禁用它,例如在编写自定义 conda-forge 食谱时),这将完全改变您的构建过程。当此文件存在时,Pip 会创建一个虚拟环境,安装它在这里看到的内容,然后构建一个轮子(如 PEP 518 中所述)。然后它会丢弃该环境,并安装轮子。这a) 使构建过程可重复,并且b) 使本地开发人员安装与标准安装过程一致。此外,c) 构建需求不会泄漏到开发或安装环境中 - 例如,您不需要在开发环境中安装 wheel - setuptools 通过 PEP 517 声明它需要它(并且仅在构建轮子时需要!)。它还d) 允许完全指定 setup.py 运行的环境,因此您可以在 setup.py 中添加可以导入的包。您不应该使用 setup_requires;它不能正常工作并且已弃用。如果您希望在 Pip 9 或更早版本(不常见)中使用源代码构建,您应该提供有关如何安装运行 setup.py 所需需求的开发说明。

您还可以使用它来选择整个构建系统;我们在上面使用 setuptools,但您也可以使用其他系统,例如 FlitPoetry。这是由于 build-backend 选择而可能的,如 PEP 517 中所述。在科学 Python 包中,使用这些“超现代”打包工具正在增长。所有工具都会构建相同的轮子(并且它们通常也会构建与 setuptools 兼容的 SDist)。

PP003 请注意,"wheel" 从不需要;它仅在需要时由 setuptools 自动注入。

特殊添加:NumPy

您可能希望针对 NumPy 构建(主要针对 Cython 包,pybind11 不需要访问 NumPy 头文件)。这是对支持旧版本 NumPy 的科学 Python 包的建议

requires = [
    "oldest-supported-numpy",

这确保了构建的轮子适用于您的包支持的所有版本的 NumPy。无论您是在本地还是在 CI 上构建轮子,您都可以将其传递给其他人,并且它将在任何支持的 NumPy 上运行。 oldest-supported-numpy 包是 NumPy 开发人员提供的 SciPy 元包,它跟踪针对每个 Python 版本以及每个操作系统/实现构建轮子时正确的 NumPy 版本。否则,您将不得不在这里列出每个 Python 版本支持的最早版本的 NumPy。

现代版本的 NumPy(1.25+)允许您在构建时针对旧版本,这强烈推荐,并且在 NumPy 2.0 中将变为必需。现在您添加

#define NPY_TARGET_VERSION NPY_1_22_API_VERSION

(其中该数字是您作为最小值支持的任何版本),然后确保您使用 NumPy 1.25+(或 2.0+,当它发布时)构建。

版本控制(中等/高优先级)

科学 Python 包应该使用以下系统之一

Git 标签:官方 PyPA 方法

在您的 pyproject.toml 文件中,还有一个部分非常有用

requires = [
    "setuptools>=42",
    "setuptools_scm[toml]>=3.4",
    # ...
]

[tool.setuptools_scm]
write_to = "src/<package>/_version.py"

这将在您从 GitHub 仓库构建时写入一个版本文件。您将获得以下好处

  • 没有需要手动更改的包含版本号的文件 - 始终与 Git 同步
    • 更简单的发布流程
    • 没有更多错误/混淆
    • 您可以使用环境变量 SETUPTOOLS_SCM_PRETEND_VERSION 强制使用版本,而无需更改源代码。
  • 每次提交都会产生一个新版本(自上次标签以来的提交次数和编码的 Git 哈希值)
    • 二进制文件不再在从 Git 中直接安装 pip 时错误地“缓存”
    • 您可以始终确定每个 sdist/wheel/安装的确切提交
    • 如果您的工作目录是“脏的”(更改/未被忽略的新文件),您将获得一个表示此情况的版本。
  • SDist 和轮子包含版本文件/号码,就像普通文件一样
    • 请注意,读取 SDist 的版本号需要 setuptools_scmtoml,除非您在 setup.py 中添加一个解决方法。对于轮子来说,这不是必需的(setup.py 甚至不是轮子的部分)。

如果您想设置一个模板,您可以控制此文件的详细信息(如果需要为了历史兼容性),但对于新的/年轻的项目,最好使用默认布局并在您的 __init__.py 中包含它

from ._version import version as __version__

在文档中,需要进行一些小的调整,以确保正确获取版本;只需确保您安装了包并从那里访问它即可。

只有一个地方没有获取到 pep518 的要求,那就是手动运行 setup.py,例如在执行 python setup.py sdist1。如果你缺少 setuptools_scm,或者可能是旧版本 setuptools_scm 上的 toml 依赖项,你将静默地得到版本 0.0.0。为了使这个错误更易于理解,请将以下内容添加到你的 setup.py

import setuptools_scm  # noqa: F401

如果你想在版本之间创建工件,那么你应该在你的 CI 中禁用浅层检出,因为一个没有标签的版本无法从一个太浅的检出中正确计算出来。对于 GitHub Actions,使用 actions/checkout@v2with: fetch-depth: 0

对于 GitHub Actions,你可以添加几行代码,使你能够手动触发带有自定义版本的构建

on:
  workflow_dispatch:
    inputs:
      overrideVersion:
        description: Manually force a version
env:
  SETUPTOOLS_SCM_PRETEND_VERSION: ${{ github.event.inputs.overrideVersion }}

如果你在触发手动工作流运行时填写了覆盖版本设置,该版本将被强制使用,否则将按正常方式工作。

确保你有一个好的 gitignore,可以从 GitHub 的 Python gitignore 开始,或者使用 生成器网站

你应该也添加这两个文件

.git_archival.txt:

node: $Format:%H$
node-date: $Format:%cI$
describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$

以及 .gitattributes(或者如果你已经在使用此文件,请添加这一行)

.git_archival.txt  export-subst

这将允许 git 存档(包括从 GitHub 生成的存档)也支持版本控制。这只有在 setuptools_scm>=7 时才有效(不过添加这些文件对旧版本不会造成任何影响)。

经典的源代码版本控制

最近版本的 setuptools 改进了源代码版本控制。如果你有一个简单的文件,其中包含一行简单的 PEP 440 样式的版本,比如 version = "2.3.4.beta1",那么你可以在你的 setup.cfg 中使用类似这样的行

[metadata]
version = attr: package._version.version

Setuptools 将在 _version.py 的 AST 中查找一个简单的赋值;如果成功,它实际上不会在安装阶段导入你的包(这是不好的)。旧版本的 setuptools 或复杂的版本文件将导入你的包;如果它在只有 pyproject.toml 要求的情况下不可导入,这将失败。

Flit 将始终查找 package.__version__,因此始终会导入你的包;如果你使用 Flit,你就必须处理这种情况。

安装配置(中等优先级)

你应该尽可能地将内容放到你的 setup.cfg 中,并将 setup.py 用于需要自定义逻辑或二进制构建的部分。这将使你的 setup.py 更简洁,而且许多通常需要一些自定义代码才能完成的事情可以在没有它的情况下完成,例如导入版本和描述。 官方文档对于 setup.cfg 来说非常棒。以下是一个实际的例子

[metadata]
name = package
description = A great package.
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/organization/package
author = My Name
author_email = me@email.com
maintainer = My Organization
maintainer_email = organization@email.com
license = BSD-3-Clause
license_files = LICENSE
classifiers =
    Development Status :: 4 - Beta
    Intended Audience :: Developers
    Intended Audience :: Information Technology
    Intended Audience :: Science/Research
    License :: OSI Approved :: BSD License
    Operating System :: MacOS
    Operating System :: Microsoft :: Windows
    Operating System :: POSIX
    Operating System :: Unix
    Programming Language :: C++
    Programming Language :: Python
    Programming Language :: Python :: 3 :: Only
    Programming Language :: Python :: 3.9
    Programming Language :: Python :: 3.10
    Programming Language :: Python :: 3.11
    Programming Language :: Python :: 3.12
    Programming Language :: Python :: 3.13
    Topic :: Scientific/Engineering
    Topic :: Scientific/Engineering :: Information Analysis
    Topic :: Scientific/Engineering :: Mathematics
    Topic :: Scientific/Engineering :: Physics
    Topic :: Software Development
    Topic :: Utilities
project_urls =
    Documentation = https://package.readthedocs.io/
    Bug Tracker = https://github.com/organization/package/issues
    Discussions = https://github.com/organization/package/discussions
    Changelog = https://package.readthedocs.io/en/latest/changelog.html


[options]
packages = find:
install_requires =
    numpy>=1.19.3
python_requires = >=3.9
include_package_data = True
package_dir =
    =src
zip_safe = False

[options.packages.find]
where = src
# Not needed unless not following the src layout
# exclude =
#     tests
#     extern

此外,一个可能的 setup.py;尽管在最近版本的 pip 中,不再需要包含一个传统的 setup.py 文件,即使是可编辑的安装,除非你在构建扩展。

#!/usr/bin/env python
# Copyright (c) 2020, My Name
#
# Distributed under the 3-clause BSD license, see accompanying file LICENSE
# or https://github.com/organization/package for details.

from setuptools import setup

setup()

请注意,我们不建议覆盖或更改 python setup.py testpython setup.py pytest 的行为;通过 setup.py 的测试命令已过时且不建议使用 - 任何直接调用 setup.py 的操作都假设存在一个 setup.py,这对于 Flit 包和其他系统来说是不正确的2。相反,假设用户直接调用 pytest,或者使用 nox

如果你需要有自定义的包数据,例如存储在 SDist 结构中的一个地方的数据在包中显示在另一个地方,那么用 options.package_data 部分和一个映射来代替 include_package_data

除了 flake8 之外,所有包配置都应该可以通过 pyproject.toml 完成,例如 pytest (6+)

[tool.pytest]
junit_family = "xunit2"
testpaths = ["tests"]

扩展(低/中优先级)

建议使用扩展来代替或除了制作需求文件。这些扩展 a) 与安装要求和其他内置工具正确交互,b) 在通过 PyPI 安装时直接可用,c) 在 requirements.txtinstall_requirespyproject.toml 以及大多数其他传递需求的地方都是允许的。

以下是一个简单扩展的例子,它放置在名为 package 的包的 setup.cfg

[options.extras_require]
test =
  pytest >=6.0
mpl =
  matplotlib >=2.0

还有一个复杂的例子,它做了一些逻辑(比如将需求组合成一个“all”扩展),它放置在 setup.py

extras = {
    "test": ["pytest"],
    "docs": [
        "Sphinx>=2.0.0",
        "recommonmark>=0.5.0",
        "sphinx_rtd_theme",
        "nbsphinx",
        "sphinx_copybutton",
    ],
    "examples": ["matplotlib", "numba"],
    "dev": ["pytest-sugar", "ipykernel"],
}
extras["all"] = sum(extras.values(), [])

setup(extras_require=extras)

自依赖关系可以放在 setup.cfg 中,使用包的名称,例如 dev = package[test,examples],但这需要 Pip 21.2 或更高版本。我们建议至少提供 testdocsdev

在 SDist 中包含/排除文件

如果你有上面推荐的 pyproject.toml 文件,Python 打包会经过一个三阶段的过程。如果你输入 pip install .,那么

  1. 源代码会被处理成一个 SDist(在一个由 pyproject.toml 强制执行的虚拟环境中)。这个 SDist 具有 .tar.gz 文件格式。
  2. SDist 会被处理成一个轮子(同一个虚拟环境)。
  3. 轮子会被安装。

轮子包含 setup.*pyproject.toml 或其他构建代码。它只是一个带有 .whl 后缀的 .zip 格式的存档,并且有一个简单的目录到安装路径的映射和一个通用的元数据格式。“安装”实际上只是复制文件,pip 还会在复制文件的同时预先编译一些字节码。

如果你没有 MANIFEST.in,那么“传统”的构建过程将跳过 SDist 阶段,这使得开发构建可以工作,而发布的 PyPI SDist 可能会失败。此外,开发模式(-e)也不包括在这个过程中,因此你应该至少有一次 CI 运行不包含 -e(pip 21.3+ 才能支持非 setuptools 可编辑安装)。

进入 SDist 的文件由 MANIFEST.in 控制,通常应该指定它。如果你使用 setuptools_scm默认情况下应该是所有 git;如果你不使用它,默认情况下是几个常见文件,比如任何 .py 文件和标准工具。以下是一个有用的默认值,不过请确保更新它以包含需要包含的任何文件

graft src
graft tests

include LICENSE README.md pyproject.toml setup.py setup.cfg
global-exclude __pycache__ *.py[cod] .venv

命令行

如果你想发布一个用户可以从命令行运行的“应用程序”,你需要添加一个 console_scripts 入口点。它的格式是

[options.entry_points]
console_scripts =
    cliapp = package.__main__:main

它的格式是命令行应用程序名称作为键,值是函数的路径,后面跟着一个冒号,然后是调用的函数。如果你使用 __main__.py 作为文件,那么 python -m + 模块也可以用来调用应用程序。

  1. 你永远不应该运行这样的命令,它们是 setuptools 的实现细节。对于此命令,你应该使用 python -m build -s 代替(以及 pip install build)。 

  2. 实际上,Flit 在制作 SDist 时默认会生成一个向后兼容的 setup.py - 它只是在 GitHub 存储库中“丢失”了。不过,这种默认行为正在改变,因为今天没有太多理由再使用传统的 setup.py 了。