1 前言

当我们开发一个Python库或者封装了一套代码逻辑后,我们会纠结于如何在多个项目中应用。

如果采用最笨的办法,也就是代码复用,那就可能会面临以下问题:

  • 当源码有bug时,需要手动替换其他的库
  • 项目A为了实现自己的需求,修改了库,项目B是否需要同步? 在C++项目中,如果采取源码复用,往往可以使用submodule配合makefileCMake实现库的顺序编译和引用,但如果库中同时包含了多种语言实现,那其中的Python代码目录往往是较深的,引用起来非常不好看。 于是通用的做法就是将库封装成whl文件,必要时上传到Pypi中做到共享和版本控制。

2 步骤

参考:实战教程:如何将自己的 Python 包发布到 PyPI 上 · 测试之家 (testerhome.com)

2.1 创建项目

或许可以更简单点

已经有大神封装了库的模板,完全可以参考该模板的形式构建:navdeep-G/setup.py: 📦 A Human’s Ultimate Guide to setup.py. (github.com)

我们为此准备一个简单的测试项目:ZhengqiaoWang/JDistributer (github.com)

这个项目的Python部分是开源的,C++部分是闭源的,所以看起来好像是多种语言的目录结构,但实际上只有Python代码。我们看一下整体目录结构:

.
├── CMakeLists.txt
├── LICENSE
├── README.md
├── examples
├── include
│   └── jdistributer
├── python
│   ├── JDistributer
│   │   ├── __init__.py
│   │   ├── adapter
│   │   │   └── mq
│   │   │       └── redis_adapter.py
│   │   ├── common_define.py
│   │   ├── noreply_consumer.py
│   │   ├── noreply_producer.py
│   │   ├── reply_consumer.py
│   │   ├── reply_group_consumer.py
│   │   └── reply_producer.py
│   ├── example
│   │   └── test.py
│   ├── setup.py
│   └── ut
│       ├── config.py
│       ├── test_group_reply.py
│       ├── test_no_reply.py
│       └── test_reply.py
└── ChangeLog
 
9 directories, 18 files

看起来有很多啊,实际上我们主要需要关注的只有以下几部分:

.
├── LICENSE # 协议
├── README.md # 帮助介绍
├── python # Python库
│   ├── JDistributer # 代码目录
│   ├── example # 示例目录
│   ├── setup.py # 核心的setup.py
│   └── ut # 单元测试目录
└── ChangeLog # 放变更记录的,版本号从这里面取

当然,如果你是纯Python库的话,可以将python目录内的东西移出来,变成这样的:

.
├── LICENSE # 协议
├── README.md # 帮助介绍
├── JDistributer # 代码目录
├── example # 示例目录
├── setup.py # 核心的setup.py
└── ut # 单元测试目录
└── ChangeLog # 放变更记录的,版本号从这里面取

根据需要进行调整,具体差异主要体现在setup.py里的目录结构上,我仍然以JDistributer项目的目录结构做示例,在与一般纯Python项目中的不同之处我会标注出来。

2.2 安装依赖

通过命令

pip3 install setuptools wheel twine

安装我们需要的依赖,其中

  • setuptools:用于打包
  • wheel:用于打包成whl文件
  • twine:用于上传到Pypi

2.3 创建setup.py

在我们创建好目录结构后,我们就可以修改setup.py了。模板可以参考setup.py/setup.py at master · navdeep-G/setup.py (github.com),我在它的基础上进行了修改ZhengqiaoWang/setup.py: 📦 A Human’s Ultimate Guide to setup.py. (github.com)

#!/usr/bin/env python
# -*- coding: utf-8 -*-
 
# NOTE 如果你想用`upload`功能,则需要在环境里安装`twine`
 
import io
import os
import sys
from shutil import rmtree
 
from setuptools import find_packages, setup, Command
 
here = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), ".."
)  # NOTE 这里因为我的基本路径是`setup.py`文件的上一层,因此额外往上走了一级
 
# 包的基本信息.
NAME = "JDistributer"
DESCRIPTION = "JDistributer Demo"
URL = "https://github.com/ZhengqiaoWang/JDistributer"
EMAIL = "me@zhengqiao.wang"
AUTHOR = "ZhengqiaoWang"
REQUIRES_PYTHON = ">=3.6.0"
 
# 代码库运行时的依赖
REQUIRED = [
    "redis",
    "hiredis",
]
 
# 可选安装什么依赖
EXTRAS = {
    # 'fancy feature': ['django'],
}
# 将ChangeLog第一行读成VERSION
try:
    with io.open(os.path.join(here, "ChangeLog")) as f:
        VERSION = f.readline().strip("\n").strip()
except FileNotFoundError:
    VERSION = "0.1.0"  # 如果没有ChangeLog,则在这里设置版本
 
# 下面的基本上不怎么动,除非你要修改协议
 
# 将README.md文件读成long-description
try:
    with io.open(os.path.join(here, "README.md"), encoding="utf-8") as f:
        long_description = "\n" + f.read()
except FileNotFoundError:
    long_description = DESCRIPTION
 
about = {}
about["__version__"] = VERSION
 
 
class UploadCommand(Command):
    """Support setup.py upload."""
 
    description = "Build and publish the package."
    user_options = []
 
    @staticmethod
    def status(s):
        """Prints things in bold."""
        print("\033[1m{0}\033[0m".format(s))
 
    def initialize_options(self):
        pass
 
    def finalize_options(self):
        pass
 
    def run(self):
        try:
            self.status("Removing previous builds…")
            rmtree(os.path.join(here, "dist"))
        except OSError:
            pass
 
        self.status("Building Source and Wheel (universal) distribution…")
        os.system("{0} setup.py sdist bdist_wheel --universal".format(sys.executable))
 
        self.status("Uploading the package to PyPI via Twine…")
        os.system("twine upload dist/*")
 
        self.status("Pushing git tags…")
        os.system("git tag v{0}".format(about["__version__"]))
        os.system("git push --tags")
 
        sys.exit()
 
 
# Where the magic happens:
setup(
    name=NAME,
    version=about["__version__"],
    description=DESCRIPTION,
    long_description=long_description,
    long_description_content_type="text/markdown",
    author=AUTHOR,
    author_email=EMAIL,
    python_requires=REQUIRES_PYTHON,
    url=URL,
    packages=find_packages(
        exclude=["tests", "*.tests", "*.tests.*", "tests.*", "ut", "example"]
    ),
    # If your package is a single module, use this instead of 'packages':
    # py_modules=['mypackage'],
    # entry_points={
    #     'console_scripts': ['mycli=mymodule:cli'],
    # },
    install_requires=REQUIRED,
    extras_require=EXTRAS,
    include_package_data=True,
    license="MIT",
    classifiers=[
        # Trove classifiers
        # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
        "License :: OSI Approved :: MIT License",
        "Programming Language :: Python",
        "Programming Language :: Python :: 3",
        "Programming Language :: Python :: 3.6",
        "Programming Language :: Python :: Implementation :: CPython",
        "Programming Language :: Python :: Implementation :: PyPy",
    ],
    # $ setup.py publish support.
    cmdclass={
        "upload": UploadCommand,
    },
)

2.3.1 修改ChangeLog

如果你打算使用ChangeLog而不是手动指定版本号,那么你就需要在根目录创建一个ChangeLog文件,并写成以下形式:

0.0.2
20240102
Hello World
 
0.0.1
20240101
DEMO

这样就会自动读取第一行0.0.2作为版本号。

2.4 打包与调试

我们准备好了后,就可以先手动试一试了。

我们先进入python目录

cd JDistributer/python

2.4.1 测试

使用命令

python3 setup.py test

可以对现在的setup.py进行测试,当提示ok时,说明写的没问题。

2.4.2 构建

使用命令

python3 setup.py sdist bdist_wheel --universal

会在目录下创建一个dist目录,里面有打包生成的文件。需要注意的是,文件与上面构建命令一一对应:

  • sdist:打包成tar.gz压缩包
  • bdist_wheel:打包成whl

2.4.3 调试

如果你期望确认一下打的包是否可用,你可以使用

python3 setup.py develop

来使我们的包JDistributer生效。目的在于,pip严格按照版本号校验升级,如果版本号一致则不会安装,倘若我们通过install来校验,每次同版本号变更就需要先卸载,太麻烦了。

当一切验证完成,我们就可以考虑发布了。

2.5 发布

我们要去https://pypi.org/account/register/注册账号,后续上传包时需要使用。注意需要及时验证邮箱。

接下来我们需要生成API TOKEN,好像从某一个时间点开始,不能直接通过账号密码登录了。

我们先访问Create API token · PyPI,创建一个Token,如果之前没创建项目的就先选择允许所有项目吧。复制好生成的Token

这个TOKEN每次都需要

请注意参照生成Token后页面展示的帮助,默认情况下这个Token不会保存,每次上传都需要! 或者你可以修改$HOME/.pypirc文件,将Token写入

修改$HOME/.pypirc,写入Token

[pypi]
  username = __token__
  password = pypi-[刚才复制的Token]

然后在刚才的JDistributer/python目录,我们执行

twine upload dist/*

回车便开始上传。

wzq@WZQ-Laptop:~/JDistributer/python$ twine upload dist/* 
Uploading distributions to https://upload.pypi.org/legacy/
Uploading JDistributer-0.0.2-py2.py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 15.2/15.2 kB 00:00 ?
 
View at:
https://pypi.org/project/JDistributer/0.0.2/

然后我们就可以在JDistributer · PyPI上看到了。

2.6 省事做法

还记得咱们刚才写的setup.py里的upload方法么?

走完一边后,直接执行

python3 setup.py upload

直接打包、上传、打tag一条龙。