Build a Python Package Step by Step

2018.03.14
SF-Zhou

今天是 π 节,祝大家 π 节快乐 :D

通常大家使用 Python 提供的包的时候,无外乎使用 pip 完成安装,然后在源代码头部使用 import 引入需要的包,再来就可以愉快地使用了。对于初学者来说,可能会困惑,要如何构建这样一个简单易用的包呢?

由此本文应运而生,这里将带大家一步一步实现一个这样的 Python 包。包的功能是封装钉钉群聊中自定义机器人的 HTTP API,方便 Python 用户直接调用该包实现机器人的消息发送。点击此处可以查看钉钉自定义机器人的文档

整个过程包含以下几步:

  1. 编写功能代码;
  2. 编写单元测试;
  3. 构建 Python 包;
  4. 上传 Python 包到 PyPI
  5. 配置 CI;
  6. 配置 CD。

1. 编写功能代码

根据钉钉自定义机器人的文档,可以通过向如下的链接发送 POST 请求来实现发送消息的功能。

https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxx

链接中的 xxxxxxxxToken,该值可以在机器人的配置中找到。

发送文本消息需要 POST 的内容如下,发送其他消息类型可以参看文档。

{
  "msgtype": "text", 
  "text": {
    "content": "我就是我, 是不一样的烟火"
  }
}

发送 POST 请求的操作可以使用 Python 中的 requests 库来实现,下面就可以写代码了。

这里将该库命名为 dingtalk_robot。找一个空文件夹,新建一个 dingtalk_robot.py 文件。这里确定仅需要一个源代码文件,如果有多个源代码文件,可以建立一个名为 dingtalk_robot 的文件夹,并在该文件夹中使用 __init__.py 引入各个源代码文件。

dingtalk_robot.py 中,实现钉钉自定义机器人的 HTTP API:

import requests


class DingtalkRobot:
    BaseUrl = 'https://oapi.dingtalk.com/robot/send?access_token='

    def __init__(self, token: str):
        self.token = token
        self.access_url = self.BaseUrl + token

    def send_text(self, content: str):
        message = {
            'msgtype': 'text',
            'text': {
                'content': content
            }
        }

        response = requests.post(url=self.access_url, json=message)
        status = response.json()
        if status['errcode'] != 0:
            raise DingtalkRobot.Error('Error Code: {}, {}'.format(
                status['errcode'],
                status['errmsg']
            ))

    class Error(ValueError):
        pass

注意到代码中自定义了一个 Error 类。钉钉自定义机器人的 HTTP 接口有状态返回码,如果返回值异常则抛出这样一个 Error

另外需要注意 requests 可能也会抛出网络相关的异常类,使用包的过程中同样需要做好异常处理。

至此本步骤完成。

2. 编写单元测试

单元测试,是针对程序模块来进行正确性检验的测试工作。Python 自带了单元测试框架 unittest点击此处可以查看 unittest 的官方文档

单元测试简单来说,就是在正常使用一个类或者一个函数的功能,并在测试中对比预期结果与运行结果是否一致。如果不一致则说明代码出了问题。

在同上的文件夹中,新建单元测试文件 test_dingtalk_robot.py。按照 unittest 的文档,编写如下的测试代码:

import unittest
from dingtalk_robot import DingtalkRobot


class MyTestCase(unittest.TestCase):
    valid_token = 'e2bfbac46ed921563dcd852ae65b3adc7797db997ea6c2cc75843b74e4365842'
    invalid_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

    def test_send_text(self):
        dingtalk_robot = DingtalkRobot(token=self.valid_token)
        success = dingtalk_robot.send_text('Send text to Dingding')
        self.assertTrue(success)

    def test_send_text_to_invalid_token(self):
        dingtalk_robot = DingtalkRobot(token=self.invalid_token)
        error_here = False

        try:
            dingtalk_robot.send_text('Send text to Dingding')
        except dingtalk_robot.Error as e:
            error_here = True

        self.assertTrue(error_here)


if __name__ == '__main__':
    unittest.main()

这里分别测试了向正常 token 和异常 token 发送消息。正常 token 发送成功,而异常 token 发送失败抛出异常。

测试代码完成后,可以运行单元测试命令进行测试。这里推荐使用 pytest。使用 pip 安装,然后执行 pytest,得到如下的输出:

==================== test session starts =====================
platform darwin -- Python 3.6.4, pytest-2.9.2, py-1.4.33, pluggy-0.3.1
benchmark: 3.1.1 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/sfzhou/Code/07_Python/04_DingtalkRobot, inifile:
plugins: tornado-0.4.5, timeout-1.2.1, pep8-1.0.6, cov-2.5.1, benchmark-3.1.1
collected 2 items

test_dingtalk_robot.py ..

================== 2 passed in 0.52 seconds ==================

可以看到两个测试均通过,本步骤完成。

3. 构建 Python 包

接下来可以打包上述的 dingtalk_robot 工具了。Python 的打包还是比较简单的,编写一个 setup.py,写入相关信息即可。点击此处可以查看打包的详细文档

按照文档,继续新建一个 setup.py 文件,写入如下代码:

"""
Python Package for Dingtalk Robot
Author: SF-Zhou
Date: 2018-03-15
"""

import dingtalk_robot
from setuptools import setup


setup(
    name=dingtalk_robot.__name__,
    version=dingtalk_robot.__version__,
    description=dingtalk_robot.__description__,
    url=dingtalk_robot.__github__,
    author=dingtalk_robot.__author__,
    author_email=dingtalk_robot.__email__,

    license='MIT',
    classifiers=[
        'Development Status :: 3 - Alpha',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: MIT License',
        'Programming Language :: Python :: 3',
    ],

    keywords='tools dingtalk robot',
    py_modules=['dingtalk_robot'],
    install_requires=['requests'],
    extras_require={'test': ['pytest']}
)

可以看到,这里引入了一个 setup 函数,并且向该函数传递了很多关于 dingtalk_robot 的信息。上述的 dingtalk_robot 的信息还需要在 dingtalk_robot.py 中补充,在 dingtalk_robot.py 文件头部可以继续加上:

__version__ = '0.0.1'
__author__ = 'SF-Zhou'
__email__ = 'sfzhou.scut@gmail.com'
__github__ = 'https://github.com/SF-Zhou/DingtalkRobot'
__description__ = 'Python Package for Dingtalk Robot'

install_requires 参数中描述了该包的依赖,dingtalk_robot 的外部依赖只有 requests。对于一个 Python 项目通常也会使用 requirements.txt 来描述依赖,故而新建一个 requirements.txt,文件内容仅填入一行 requests 即可。当其他人需要手动安装依赖的时候,可以执行:

pip3 install -r requirements.txt

各项配置工作均已完成,下面可以执行 build 命令构建包:

python setup.py build

可以看到类似如下的输出:

running build
running build_py
creating build
creating build/lib
copying dingtalk_robot.py -> build/lib
warning: build_py: byte-compiling is disabled, skipping.

如果希望把该包安装到本地,可以执行:

python setup.py install

这样在本地的环境中,可以直接通过 import dingtalk_robot 来使用该包了。

如果希望可以将该包发布出去,被大家广泛地使用,那还需要构建一个源码包:

python setup.py sdist

可以看到类似如下的输出:

running sdist
running egg_info
creating dingtalk_robot.egg-info
writing dingtalk_robot.egg-info/PKG-INFO
writing dependency_links to dingtalk_robot.egg-info/dependency_links.txt
writing requirements to dingtalk_robot.egg-info/requires.txt
writing top-level names to dingtalk_robot.egg-info/top_level.txt
writing manifest file 'dingtalk_robot.egg-info/SOURCES.txt'
reading manifest file 'dingtalk_robot.egg-info/SOURCES.txt'
writing manifest file 'dingtalk_robot.egg-info/SOURCES.txt'
running check
creating dingtalk_robot-0.0.1
creating dingtalk_robot-0.0.1/dingtalk_robot.egg-info
copying files to dingtalk_robot-0.0.1...
copying README.md -> dingtalk_robot-0.0.1
copying dingtalk_robot.py -> dingtalk_robot-0.0.1
copying setup.py -> dingtalk_robot-0.0.1
copying dingtalk_robot.egg-info/PKG-INFO -> dingtalk_robot-0.0.1/dingtalk_robot.egg-info
copying dingtalk_robot.egg-info/SOURCES.txt -> dingtalk_robot-0.0.1/dingtalk_robot.egg-info
copying dingtalk_robot.egg-info/dependency_links.txt -> dingtalk_robot-0.0.1/dingtalk_robot.egg-info
copying dingtalk_robot.egg-info/requires.txt -> dingtalk_robot-0.0.1/dingtalk_robot.egg-info
copying dingtalk_robot.egg-info/top_level.txt -> dingtalk_robot-0.0.1/dingtalk_robot.egg-info
Writing dingtalk_robot-0.0.1/setup.cfg
creating dist
Creating tar archive
removing 'dingtalk_robot-0.0.1' (and everything under it)

并且当前文件夹下会多出两个新文件夹:distdingtalk_robot.egg-infodist 文件夹中有一个名为 dingtalk_robot-0.0.1.tar.gz 的压缩包,该包的内容为如下:

可以看到这里包含了源代码文件,还有一些 INFO 文件。将该文件发布出去,其他人也可以轻松地安装并使用 dingtalk_robot 了。

至此本步骤完成。

4. 上传 Python 包到 PyPI

Python Package Index,简称 PyPI,为 Python 官方的软件库。可以在 PyPI 中查看、下载所有公开的 Python 包,也可以发布自己的 Python 包供大家使用。当然,首先需要注册一个账号,可以点击此处注册

拥有账号后,就可以使用 twine 上传上一步骤中构建的 Python 包了。

使用 pip 安装 twine 后,需要简单配置一下。在 HOME 目录下新建一个 .pypirc 文件,即 ~/.pypirc,填入以下信息:

[distutils]
index-servers=pypi

[pypi]
username = sfzhou
password = xxxxxxxx

上方的 usernamepassword 替换为 PyPI 中的用户名和密码。然后在包所在的目录执行:

twine upload dist/dingtalk_robot-0.0.1.tar.gz

上传上一步骤中构建的 dingtalk_robot 包,可以看到类似如下的输出:

Uploading distributions to https://upload.pypi.org/legacy/
Uploading dingtalk_robot-0.0.1.tar.gz
100%|████████████████████████████████████████████| 4.57k/4.57k [00:01<00:00, 4.09kB/s]

如果显示有其他错误,则按照错误提示修正即可。比如笔者就遇到了注册邮箱没有验证的错误,完整邮箱验证后上传成功。

上传成功后,就可以在 PyPI 页面看到 dingtalk_robot 的信息了。也可以直接通过 pip 命令实现安装:

pip install dingtalk_robot

进而每个人都可以方便地安装和使用这个小工具了。

5. 配置 CI

Continuous Improvement,简称 CI,也就是常说的持续集成。该步骤并不是必须的,但是配置有持续集成的项目更容易得到大家的信赖:持续集成意味着项目提供了准确的环境配置,并且通过了多项测试。

目前 GitHub 上常用的 CI 平台有 Travis-CIAppVeyorCircle-CI。本文以 Travis-CI 为例介绍 CI 的配置,点击此处可以查看 Travis-CI 的详细文档

在项目文件夹中新建 Travis-CI 的配置文件 .travis.yml,文件内容为:

language: python
python:
    - "3.4"
    - "3.5"
    - "3.6"

install:
    - pip install -r requirements.txt
    - pip install pytest pycodestyle

script:
    - pycodestyle . --max-line-length=120
    - pytest

该文件首先定义了持续集成的环境为 Python,包含 3.4,3.5,3.6 三个版本。而后定义了 install 项,使用 pip 安装了运行和测试所需要的依赖。最后执行了代码风格测试和单元测试。

假设项目的文件夹已经建立好了 Git 库并上传到了 GitHub 上。例如本文中的 dingtalk_robot,就放在 https://github.com/SF-Zhou/DingtalkRobot 中。这里还需要在 Travis-CI 中加入该项目,即在加入项目的页面打钩即可:

至此配置完成,之后每次 commit 或 pull request 后,Travis-CI 会自动执行配置中的脚本,完成代码风格测试和单元测试。

不幸的是这里没有通过测试:

可以点击失败的 Job 查看详情。原来是是因为 setup.py 文件末尾没有空行,导致代码风格检查失败:

$ pycodestyle . --max-line-length=120
./setup.py:31:2: W292 no newline at end of file
The command "pycodestyle . --max-line-length=120" exited with 1.

setup.py 文件末加入空行,再次提交,测试通过:

通常 CI 平台会提供项目状态的标志,点击上图上方中的 build|unknown 图标,可以得到状态标志的链接:

将该链接加入 README.md 中,之后在 GitHub 的项目页面就可以看到绿色的 Build Passing 标志了。

至此本步骤完成。

6. 配置 CD

Continuous Deployment,简称 CD,持续部署。该步骤同样不是必须的,简单来说就是省事而已。

dingtalk_robot 通过单元测试后,下一步肯定是及时地发布到 PyPI 上。CD 的目的就是为我们自动化地完成这件事。Travis-CI 提供了非常方便的 PyPI 发布配置,点击此处可以查看相关文档

.travis.yml 底部继续加入:

deploy:
    provider: pypi
    user: "sfzhou"
    on:
      tags: true
      python: 3.6

当然发布还需要密码,而这里并不能直接把密码放到 GitHub 中。所以 Travis-CI 提供了一个加密密码的工具。使用 pip 安装名为 travis 的小工具,在项目目录下执行:

travis encrypt --add deploy.password

按照提示输入密码,成功后会自动把加密的密码写入 .travis.yml 中。最后将变更提交。

注意,这里配置了当且仅当运行环境为 Python 3.6,且附带 tag,才会执行发布的操作。之后需要发布的话,可以使用类似如下的命令:

# change __version__ to 0.0.5 in dingtalk_robot.py

git checkout master
git tag V0.0.5
git push origin master --tag

至此本步骤完成。

7. 总结

一个完整的 Python 包构建过程就结束了。麻雀虽小五脏俱全,大项目也是这样一步一步构建出来的。随着后续的继续迭代,单元测试和 CI 持续保证质量,CD 及时将包发布到 PyPI 上,GitHub 中接收社区的反馈和贡献,这个包也就会越来越完善,越来越稳定。

本文中的源代码可以在 SF-Zhou/DingtalkRobot 中找到,其中的每一个 commit 对应文中的每一步。