打包和分发Python程序

示例工程位于:zjZSTU/python-setup.py

内容列表

  • 相关术语介绍
  • 打包和分发流程
    • 创建setup.py
    • 打包Python Package
    • 注册pypi帐号
    • 上传Python Package
    • 安装Python Package
  • 进阶
    • 免密上传
    • 配置版本号
    • 配置依赖库
    • 配置命令行脚本
    • 创建徽章
  • 最终版本
    • 实现打包、上传和GIT标签一条龙服务
  • 问题

相关术语介绍

    • setuptools:新一代打包和分发Python Packages的工具(之前是distutils
  • 命令行工具
    • twine:上传构建文件的工具
    • pip:安装Python包的工具,从pypi或者其他软件仓库中下载包并安装
  • 文件
    • setup.py:在项目根目录创建,指定包以及环境信息
  • 仓库
    • pypi:全称为The Python Package IndexPython编程语言的在线软件仓库

打包和分发流程

参考:

1. An Introduction to Distutils

Packaging Python Projects

Getting Started With setuptools and setup.py

创建示例工程python-setup,其文件架构如下:

.
├── lib
│   ├── __init__.py
│   └── tools
│       ├── cli.py
│       └── __init__.py
├── README.md
└── setup.py

创建setup.py

setup.py上编写相应的打包信息,其文件内容如下:

import setuptools

with open("README.md", "r") as fh:
    long_description = fh.read()

setuptools.setup(
    name="python-setup",  # Replace with your own username
    version="0.0.1",
    author="zj",
    author_email="wy163zhuj@163.com",
    description="A small example package",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="https://github.com/zjZSTU/python-setup.py.git",
    packages=setuptools.find_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
    ],
    python_requires='>=3.6',
)
  • name:工程名
  • version:版本号
  • author:作者
  • author_email:邮箱
  • description:工程短描述
  • long_description:长描述,直接使用README.md
  • url:在线仓库地址
  • packages:打包文件,有两种选择
    • 自定义:格式为packages=['xx', 'xx'],指定要打包的包目录
    • find_packages():打包所有包目录(就是有__init__.py的文件夹),可以通过参数exclude排除指定文件,比如find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"])
  • classifiers:字符串列表,描述了包类别
  • python_requires:指定Python版本

打包Python Package

执行如下命令

$ python setup.py sdist bdist_wheel

将会创建3个文件夹:build、distpython_setup.egg-info,打包程序位于dist目录下:

python_setup-0.0.1-py3-none-any.whl
python-setup-0.0.1.tar.gz

注册pypi帐号

pypi上注册帐号:Create an account on PyPI

上传Python Package

安装上传工具

pip install twine

上传Python Packages

twin upload dist/*

输入注册的用户名和密码即可,上传完成后,可以在个人工程页面查看

安装Python Package

使用pip命令

使用pip命令进行安装,有两种方式

在线下载并安装
pip install python_setup

升级已安装包

pip install --upgrade python_setup

安装指定包

pip install python_setup==x.x.x

卸载包

pip uninstall python_setup
安装本地包
pip install python_setup-0.0.1-py3-none-any.whl

使用setuptools

python setup.py install

进阶

免密上传

pypi官网推荐使用API Token的方式进行免密登录:How can I use API tokens to authenticate with PyPI?,实现后发现会出现错误,参考pypi上传问题,编辑文件.pypirc

$ vim ~/.pypirc
[distutils]
index-servers=pypi

[pypi]
repository:https://upload.pypi.org/legacy/
username:用户名
password:密码

配置版本号

参考:facebookresearch/detectron2

lib/__init__.py中设置版本信息

# This line will be programatically read/write by setup.py.
# Leave them at the bottom of this file and don't touch them.
__version__ = "0.1.0"

setup.py中解析该文件,获取版本号

import os


def get_version():
    init_py_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "lib", "__init__.py")
    init_py = open(init_py_path, "r").readlines()
    version_line = [l.strip() for l in init_py if l.startswith("__version__")][0]
    version = version_line.split("=")[-1].strip().strip("'\"")

    return version

。。。
。。。

setuptools.setup(
    。。。
    version=get_version(),
    。。。

配置依赖库

类似于requirements.txt,在安装Python包时同时下载安装相关的依赖库,使用python_requires属性设置,比如

python_requires=[
    "yacs >= 0.1.7",
    "opencv_contrib_python >= 4.2.0",
    "numpy >= 1.17.2"
]

配置命令行脚本

参考:Python中, 使用setup.py和console_scripts参数创建安装包和shell命令

新建文件lib/tools/cli.py

from lib.src.hello import print_hello

def main():
    print_hello()

if __name__ == '__main__':
    main()

修改setup.py,添加属性entry_points

    entry_points={
        'console_scripts': [
            # 注意,不要添加.py后缀
            # print_hello就是命令
            'print_hello = lib.tools.cli:main'
        ]
    },

重新打包并安装

$ python setup.py sdist bdist_wheel
$ pip install dist/python_setup-0.1.0-py3-none-any.whl

即可在全局命令行中执行命令print_hello

$ print_hello 
Hello python-setup.py

其地址位于/bin目录下

$ which print_hello 
/home/zj/anaconda3/bin/print_hello
$ file `which print_hello`
/home/zj/anaconda3/bin/print_hello: Python script, ASCII text executable
$ cat `which print_hello`
#!/home/zj/anaconda3/bin/python
# -*- coding: utf-8 -*-
import re
import sys

from python_setup.tools.cli import main

if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    sys.exit(main())

创建徽章

参考自定义徽章,为pypi项目创建徽章

最终版本

参考navdeep-G/setup.py实现打包、上传以及GIT标签一条龙服务

修改setup.py如下:

新增类UploadCommand

import shutil
import sys

class UploadCommand(setuptools.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:
            here = os.path.abspath(os.path.dirname(__file__))
            self.status('Removing previous builds…')
            shutil.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(get_version()))
        os.system('git push --tags')

        sys.exit()

执行如下功能:

  1. 删除已有的dist文件夹
  2. 打包:python setup.py sdist bdist_wheel --universal
  3. 上传到Pypi软件仓库:twine upload dist/*
  4. 打标签:git tag v{}
  5. 上传到远程Git仓库:git push --tags

更新setuptools.setup

setuptools.setup(
    ...
    ...
    cmdclass={
        'upload': UploadCommand,
    },
)

执行

使用如下命令即可完成打包、上传和贴标签一条龙服务了

$ python setup.py upload 

问题

Filename or contents already exists

参考:Why am I getting a "Filename or contents already exists" or "Filename has been previously used" error?

pypi规定指定文件名的python包只能上传一次,即使删除也不能重复上传

ModuleNotFoundError

安装完Python包后,执行命令行工具经常会出现ModuleNotFoundError错误

$ print_hello 
Traceback (most recent call last):
  File "/home/zj/anaconda3/bin/print_hello", line 6, in <module>
    from lib.tools.cli.py import main
ModuleNotFoundError: No module named 'lib.tools.cli.py'; 'lib.tools.cli' is not a package

这是因为lib这个库有冲突,最好选择一个独特的包名,比如lib -> python_setup

pip没有办法更新到最新版本

我就遇到了这个问题,想了很久觉得是因为我配置了国内pip镜像源,所以没办法马上下载到最新的版本