如何打包你的Python代码

这个教程目标是为了更好地描述打包的过程,让大家都能学会如何打包Python代码。 但是打包并非 仅仅只有 一种方式,这个教程仅仅只描述了一种可行的打包方式。

打包之后,你的代码有如下好处:

  • 可以使用 pip or easy_install 安装.
  • 可以做为其他包的依赖关系.
  • 其他用户更加方便地使用和测试你的代码.
  • 其他用户可以更方便的理解你的代码,因为你的代码是按照打包需要的格式来组织的.
  • 更加方便添加和分发文档.

我们一步一步地,制作一个简单的python包 funniest ,你就会发现我所说非虚。

最小的结构

让我们来看一小段代码:

def joke():
    return (u'How do you tell HTML from HTML5?'
            u'Try it out in Internet Explorer.'
            u'Does it work?'
            u'No?'
            u'It\'s HTML5.')

这小段代码仅仅是为了展示如何打包和分发Python代码, 没有实际的用途。

选择一个包名

Python 模块或者包名应该遵守以下的规则:

  • 全小写
  • 不要和pypi上已有的包名重复,即使你不想公开发布你的包,因为你的包可能作为其他包的依赖包
  • 使用下划线分隔单词或者什么都不用(不要使用连字符)

现在把我们的函数变成一个Python module funniest

开始工作

目录结构 funniest 如下:

funniest/
    funniest/
        __init__.py
    setup.py

最外层的目录是我们版本管理工具的根目录, 例如 funniest.git . 子目录也叫 funniest , 代表Python module.

为了更好理解, 我们把函数 joke() 放到 __init__.py 中:

def joke():
    return (u'How do you tell HTML from HTML5?'
            u'Try it out in Internet Explorer.'
            u'Does it work?'
            u'No?'
            u'It\'s HTML5.')

最主要的setup配置文件是 setup.py , 应该包含一行代码调用 setuptools.setup() ,就像下面这样:

from setuptools import setup

setup(name='funniest',
      version='0.1',
      description='The funniest joke in the world',
      url='http://github.com/storborg/funniest',
      author='Flying Circus',
      author_email='flyingcircus@example.com',
      license='MIT',
      packages=['funniest'],
      zip_safe=False)

现在我们可以在本地安装这个python包:

$ python setup.py install

我们也可以使用开发模式安装这个包, 每次修改代码之后不用重新安装, 立即可用最新的代码.:

$ python setup.py develop

不管用哪种方式,安装之后就可以在python中使用这个包:

>>> import funniest
>>> print funniest.joke()

在PyPI上发布

脚本 setup.py 也是在PyPI注册和上传源码包的入口.

第一步注册这个包(包括注册包名,上传元数据,创建pypi.python.org的页面):

$ python setup.py register

如果从未在PyPI上发布过东西, 你需要创建一个账号, 命令行向导会一步一步告诉你怎么做.

注册之后你可以在PyPI看到这个包的页面 funniest :

http://pypi.python.org/pypi/funniest/0.1

尽管用户可以根据URL链接找到你的git仓库, 但是为了使用方便我们需要上传一个源码包. 用户不需要clone你的git仓库,而且可以使用安装工具 自动化安装和搜索依赖关系.

第二步创建一个源码包:

$ python setup.py sdist

这一步会在你的顶层目录下创建 dist/funniest-0.1.tar.gz . 如果你有时间, 可以把这个文件拷贝到另一台主机上, 解压然后安装, 测试一下安装包.

第三步上传到PyPI:

$ python setup.py sdist upload

你可以把这几步结合起来, 更新元数据, 发布新版本, 一步就完成:

$ python setup.py register sdist upload

想要查看setup.py更多的功能可以看看帮助:

$ python setup.py --help-commands

安装这个包

上面的步骤完成之后, 其他用户可以直接用 easy_install 安装:

easy_install funniest

或者使用 pip

$ pip install funniest

如果这包作为其他包的依赖包, 它将被自动安装(我们在后面会提到如何配置)

添加其他文件

大部分时间我们的代码分散在多个文件当中,

举个例子, 我们把函数移动到一个新的文件中 text , 现在我们的目录结构是这样子的:

funniest/
    funniest/
        __init__.py
        text.py
    setup.py

__init__.py

from .text import joke

text.py

def joke():
    return (u'How do you tell HTML from HTML5?'
            u'Try it out in Internet Explorer.'
            u'Does it work?'
            u'No?'
            u'It\'s HTML5.')

所有的代码应该都在 funniest/funniest/ 目录下.

忽略的文件 (.gitignore, etc)

我们可能需要一个 .gitignore 或者是其他代码管理工具类似的文件, 因为创建包的过程中会产生一下中间文件, 我们并不想提交到代码仓库当中.

下面是一个 .gitignore 的例子:

# Compiled python modules.
*.pyc

# Setuptools distribution folder.
/dist/

# Python egg metadata, regenerated from source files by setuptools.
/*.egg-info

大功告成

上面讲的结构已经包含了创建一个包的所有步骤. 如果所有的Python工具和库都遵循同样的规则来打包, 世界会更加美好.

客官别急 下面还有更多内容, 因为大部分的包还需要命令行脚本, 文档, 测试,分析工具等等, 请看下一篇.

依赖关系

如果你使用Python, 很自然的你会使用其他人在PyPI或者其他地方公开发布的包

setuptools给我们提供了很方便的工具来说明依赖关系, 而且在安装我们的包的时候会自动安装依赖包.

我们可以给 funniest joke 添加一些格式, 使用 Markdown.

__init__.py

from markdown import markdown

def joke():
    return markdown(u'How do you tell HTML from HTML5?'
                    u'Try it out in **Internet Explorer**.'
                    u'Does it work?'
                    u'No?'
                    u'It\'s HTML5.')

现在我们的包依赖 markdown 这个包. 我们需要在 setup.py 中添加 install_requires 参数:

from setuptools import setup

setup(name='funniest',
      version='0.1',
      description='The funniest joke in the world',
      url='http://github.com/storborg/funniest',
      author='Flying Circus',
      author_email='flyingcircus@example.com',
      license='MIT',
      packages=['funniest'],
      install_requires=[
          'markdown',
      ],
      zip_safe=False)

为了测试是否可行,我们可以试一试 python setup.py develop

$ python setup.py develop
running develop
running egg_info
writing requirements to funniest.egg-info/requires.txt
writing funniest.egg-info/PKG-INFO
writing top-level names to funniest.egg-info/top_level.txt
writing dependency_links to funniest.egg-info/dependency_links.txt
reading manifest file 'funniest.egg-info/SOURCES.txt'
writing manifest file 'funniest.egg-info/SOURCES.txt'
running build_ext
Creating /.../site-packages/funniest.egg-link (link to .)
funniest 0.1 is already the active version in easy-install.pth

Installed /Users/scott/local/funniest
Processing dependencies for funniest==0.1
Searching for Markdown==2.1.1
Best match: Markdown 2.1.1
Adding Markdown 2.1.1 to easy-install.pth file

Using /.../site-packages
Finished processing dependencies for funniest==0.1

当我们安装funniest包的时候, pip install funniest 也会同时安装 markdown .

不在PyPI中的包

有时候, 你需要一些按照setuptools格式组织的安装包, 但是它们没有在PyPI发布. 在这种情况下, 你可以在 dependency_links 中填入下载的URL, 可能需要在URL中加一些其他信息, setuptools将根据URL找到和安装这些依赖包.

举个例子, Github上的包可以按照下面的格式填写URL:

setup(
    ...
    dependency_links=['http://github.com/user/repo/tarball/master#egg=package-1.0']
    ...
)

组织更好地元数据

setuptools.setup() 函数接受很多参数, 需要你填写关于你的包的元数据. 完整的填写这些参数可以让人们更加容易找到和判断你的包是干什么的.:

from setuptools import setup

setup(name='funniest',
      version='0.1',
      description='The funniest joke in the world',
      long_description='Really, the funniest around.',
      classifiers=[
        'Development Status :: 3 - Alpha',
        'License :: OSI Approved :: MIT License',
        'Programming Language :: Python :: 2.7',
        'Topic :: Text Processing :: Linguistic',
      ],
      keywords='funniest joke comedy flying circus',
      url='http://github.com/storborg/funniest',
      author='Flying Circus',
      author_email='flyingcircus@example.com',
      license='MIT',
      packages=['funniest'],
      install_requires=[
          'markdown',
      ],
      include_package_data=True,
      zip_safe=False)

classifiers 参数, 完整的分类列表在这里 http://pypi.python.org/pypi?%3Aaction=list_classifiers.

README / Long Description

你可能希望添加一个README说明文件到你的包中, 而且也可以满足PyPI long_description 的规范. 如果 这个文件使用reStructuredText语法, 将会有更丰富的格式.

funniest, 添加两个文件:

funniest/
    funniest/
        __init__.py
    setup.py
    README.rst
    MANIFEST.in

README.rst

Funniest
--------

To use (with caution), simply do::

    >>> import funniest
    >>> print funniest.joke()

MANIFEST.in

include README.rst

这个文件是用来告诉setuptools打包的时候把README.rst添加进去, 否则的话只会打包包含Python代码的文件.

接下来修改 setup.py

from setuptools import setup

def readme():
    with open('README.rst') as f:
        return f.read()

setup(name='funniest',
      version='0.1',
      description='The funniest joke in the world',
      long_description=readme(),
      classifiers=[
        'Development Status :: 3 - Alpha',
        'License :: OSI Approved :: MIT License',
        'Programming Language :: Python :: 2.7',
        'Topic :: Text Processing :: Linguistic',
      ],
      keywords='funniest joke comedy flying circus',
      url='http://github.com/storborg/funniest',
      author='Flying Circus',
      author_email='flyingcircus@example.com',
      license='MIT',
      packages=['funniest'],
      install_requires=[
          'markdown',
      ],
      include_package_data=True,
      zip_safe=False)

当你的代码存放在GitHub或者是BitBucket, README.rst 会自动成为项目的主页.

添加测试代码

funniest 需要一些测试工作. 这些代码都应该放在 funniest. 子模块的目录下. 这样的结构,这些测试代码既可以导入, 又不会污染全局的命名空间.:

funniest/
    funniest/
        __init__.py
        tests/
            __init__.py
            test_joke.py
    setup.py
    ...

test_joke.py 是我们第一个测试文件. 虽然现在有一些小题大做, 但是这是为了演示代码是如何组织的, 所以我们创建了 unittest.TestCase 的一个子类:

from unittest import TestCase

import funniest

class TestJoke(TestCase):
    def test_is_string(self):
        s = funniest.joke()
        self.assertTrue(isinstance(s, basestring))

运行这些测试用例最好的方式是使用 Nose (特别是你不知道用什么的时候)

$ pip install nose $ nosetests

为了把测试工作集成到 setup.py 中, 我们需要添加一些参数, 这些参数会确保运行测试用例的时候Nose会被安装.:

setup(
    ...
    test_suite='nose.collector',
    tests_require=['nose'],
)

然后, 我们就可以这样运行测试:

$ python setup.py test

setuptools 将会安装nose和运行测试用例.

命令行脚本

很多Python包都有命令行工具. 借助setuptools/PyPI你可以非常方便地添加有用的命令行工具到你发布包当中, 或者你想单纯发布使用Python编写 的命令行工具.

举个例子, 我们添加一个 funniest-joke 的可执行命令.

setuptools.setup() 中有两种方法 scripts 参数或是 console_scripts 入口.

scripts 参数

第一种方法是把你的命令写在一个单独的文件中:

funniest/
    funniest/
        __init__.py
        ...
    setup.py
    bin/
        funniest-joke
    ...

bin/funniest-joke 如下:

#!/usr/bin/env python

import funniest
print funniest.joke()

在``setup()`` 添加:

setup(
    ...
    scripts=['bin/funniest-joke'],
    ...
)

当我们安装这个包的时候, setuptools会把你的脚本复制到PATH路径下:

$ funniest-joke

使用这种方法的好处是可以使用非Python的语言的编写, funniset-joke 可以是一个shell脚本或者其他的都可以.

console_scripts 入口

第二种方法是通过’entry point’. setuptools 允许模块注册’entry points’, 这样可以使用其他包的功能. console_scripts 也是一个’entry points’.

console_scripts 允许Python的 functions (不是文件) 直接被注册成一个命令行工具.

下面, 我们将添加一个新文件提供命令行工具:

funniest/
    funniest/
        __init__.py
        command_line.py
        ...
    setup.py
    ...

command_line.py 仅仅只提供命令行工具(这样组织代码更方便):

import funniest

def main():
    print funniest.joke()

你可以测试一下, 就像这样:

$ python
>>> import funniest.command_line
>>> funniest.command_line.main()
...

在setup.py 中 注册 main()

setup(
    ...
    entry_points = {
        'console_scripts': ['funniest-joke=funniest.command_line:main'],
    }
    ...
)

我们安装了这个包之后, 我们就可以直接使用 funniest-joke 命令. 因为setuptools 会自动生成一个脚本, 包括导入模块, 然后在调用注册的函数.

添加非代码的其他文件

通常我们的包都需要一下不是python代码的文件, 例如图片, 数据, 文档等等. 为了让setuptools正确处理这些文件, 我们需要特别定义一下这些文件.

我们需要在 MANIFEST.in 中指定这些文件, MANIFEST.in 提供了一个文件清单, 使用相对路径或是绝对路径指出打包时需要包含的特殊文件.:

include README.rst
include docs/*.txt
include funniest/data.json

为了让在安装的时候这些特殊文件能被复制到 site-packages 下的文件夹中, 需要在 setup() 添加参数 include_package_data=True .

注解

添加在安装包中的文件(例如计算需要的数据文件)应该放在Python模块的文件夹里面(例如 funniest/funniest/data.json ). 在加载这些文件的时候, 使用相对路径再加上 __file__ 变量.

把所有的东西组合起来

最后整个Python包, 看起来像这样:

funniest/
    funniest/
        __init__.py
        command_line.py
        tests/
            __init__.py
            test_joke.py
            test_command_line.py
    MANIFEST.in
    README.rst
    setup.py
    .gitignore

每个文件如下:

funniest/__init__.py

from markdown import markdown

def joke():
    return markdown(u'How do you tell HTML from HTML5?'
                    u'Try it out in **Internet Explorer**.'
                    u'Does it work?'
                    u'No?'
                    u'It\'s HTML5.')

funniest/command_line.py

from . import joke


def main():
    print joke()

funniest/tests/__init__.py

(empty)

funniest/tests/test_joke.py

from unittest import TestCase

import funniest


class TestJoke(TestCase):
    def test_is_string(self):
        s = funniest.joke()
        self.assertTrue(isinstance(s, basestring))

funniest/tests/test_command_line.py

from unittest import TestCase

from funniest.cmd import main


class TestCmd(TestCase):
    def test_basic(self):
        main()

MANIFEST.in

include README.rst

README.rst

Funniest
--------

To use (with caution), simply do::

    >>> import funniest
    >>> print funniest.joke()

setup.py

from setuptools import setup

def readme():
    with open('README.rst.example') as f:
        return f.read()

setup(name='funniest',
      version='0.1',
      description='The funniest joke in the world',
      long_description=readme(),
      classifiers=[
        'Development Status :: 3 - Alpha',
        'License :: OSI Approved :: MIT License',
        'Programming Language :: Python :: 2.7',
        'Topic :: Text Processing :: Linguistic',
      ],
      keywords='funniest joke comedy flying circus',
      url='http://github.com/storborg/funniest',
      author='Flying Circus',
      author_email='flyingcircus@example.com',
      license='MIT',
      packages=['funniest'],
      install_requires=[
          'markdown',
      ],
      test_suite='nose.collector',
      tests_require=['nose', 'nose-cover3'],
      entry_points={
          'console_scripts': ['funniest-joke=funniest.command_line:main'],
      },
      include_package_data=True,
      zip_safe=False)

.gitignore

# Compiled python modules.
*.pyc

# Setuptools distribution folder.
/dist/

# Python egg metadata, regenerated from source files by setuptools.
/*.egg-info
/*.egg

关于这篇教程

Scott Torborg - storborg@gmail.com.

I wrote this tutorial in an attempt to improve the state of Python packages at large. Tools like virtualenv and pip, as well as improvements to setuptools, have made the Python package ecosystem a delight to work in.

However, I frequently run across packages I want to use that don’t interoperate correctly with others, or find myself in situations where I’m not sure exactly how to structure things. This is an attempt to fix that.

This documentation is written in reStructuredText and built with Sphinx. The source is open and hosted at http://github.com/storborg/python-packaging.

To build the HTML version, just do this in a clone of the repo:

$ make html

Contributions and fixes are welcome, encouraged, and credited. Please submit a pull request on GitHub or email me.

中文翻译 openmartin Github

中文版文档地址 http://python-packaging-zh.readthedocs.org/zh_CN/latest/index.html

注解

目前,这份教程仅仅针对Python 2.x,可能在Python 3.x 上并不适用

参见

Setuptools Documentation
setuptools documentation.
Python Packaging User Guide
“Python Packaging User Guide” (PyPUG) 目标在于为Python包如何打包和安装,提供权威的指南.