January 11, 2015 · Python

Building Your Own Python Project

最近有同事花了一點時間 survey、並開了個讀書會分享如何建立一個完整的 Python project 環境,乾脆就趁此機會寫篇文章整理一下,希望可以改善我 project 環境一向亂搞的情況(XD)。這篇筆記是基於這場讀書會的內容,略微修改成我自己喜歡的版本。

這篇筆記會包含以下幾個部分:

但這篇筆記不會教你:

不過筆記中都會附上相關的文件連結,有興趣瞭解可以自己點開進去看。

Step 1. Creating Project

首先,讓我們開個新的 project 資料夾,就叫做 demo 吧:

$ mkdir demo
$ cd demo

Step 1-1. Virtual Environment

在開始開發 project 之前,我習慣先用 virtualenv 建立一個獨立的 Python 環境,避免不同 project 或 global 環境的 Python packages 不同版本混亂的問題。

如果你的系統裡還沒有 virtualenv,可以直接利用 pip 來安裝:

$ pip install virtualenv

接著就可以直接在 project 目錄下建立專屬的 virtual environment:

$ virtualenv .venv
New python executable in .venv/bin/python2.7  
Also creating executable in .venv/bin/python  
Installing setuptools, pip...done.  

在這裡我建立了一個 Python 2.7 的 virtual environment,.venv 則是它的名稱。我個人是習慣都這樣取,你也可以自己換成喜歡的名字。

實際上,這個名稱就是存放這個 virtual environment 的資料夾名稱:

$ ls -a
.     ..    .venv

接著,我們要啟動這個 virtual environment:

$ source .venv/bin/activate

上面的指令是給 bash 的使用者用的。不過如果你像我一樣比較喜歡用 fish 的話,則要改成:

$ . .venv/bin/activate.fish

然後我們就得到一個乾淨的 Python 的環境了:

$ which python
/Users/jason2506/Projects/demo/.venv/bin/python
$ which pip
/Users/jason2506/Projects/demo/.venv/bin/pip
$ pip list
pip (6.0.6)  
setuptools (8.2.1)  

Step 1-2. Let's Coding

現在可以來開發 project 了。首先,先在目錄下建立 demo/ 資料夾當作 top-level package,並在底下建立 __init__.pydemo.py

demo/__init__.py

Top-level 的 __init__.py 通常是拿來放一些 metadata:

# -*- coding: utf-8 -*-

__version__ = '0.0.1'  
__author__ = 'Chi-En Wu'  
__email__ = 'jason2506@somewhere.com.tw'  
__license__ = 'BSD'  
__copyright__ = 'Copyright (c) 2015, Chi-En Wu.'  

demo/demo.py

接著,讓我們在 demo.py 裡頭寫一個抓取網頁 title 的無用小 function:

# -*- coding: utf-8

from __future__ import print_function

import requests  
from lxml import html


def get_title(url):  
    page = requests.get(url)
    root = html.fromstring(page.text)
    return root.findtext('.//title')


if __name__ == '__main__':  
    print(get_title('http://www.google.com/'))

requirements.txt

由於我們有用到 requestslxml 這兩個額外的 3rd-party packages,我的習慣是會在主目錄下的 requirements.txt 寫下 dependencies:

requests==2.5.1  
lxml==3.4.1  

詳細的 requirements.txt 檔案格式可以參考 pip 的文件

setup.py

一個 Python project 最核心的部分就是 setup.py 了:

# -*- coding: utf-8 -*-
"""
    Demo
    ~~~~

    Just a simplest project to show you how to build your own project.

    :copyright: (c) 2015 by Chi-En Wu <jason2506@somewhere.com.tw>.
    :license: BSD.
"""

import uuid

from pip.req import parse_requirements  
from setuptools import setup, find_packages

import demo


def requirements(path):  
    return [str(r.req) for r in parse_requirements(path, session=uuid.uuid1())]


setup(  
    name='demo',
    version=demo.__version__,
    author=demo.__author__,
    author_email=demo.__email__,
    url='http://your.host.name/demo',
    description='Just a simplest project to show you how to build your own project.',
    long_description=__doc__,
    packages=find_packages(),
    install_requires=requirements('requirements.txt'),
    classifiers=[
        'Development Status :: 1 - Planning',
        'Environment :: Console',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: BSD License',
        'Operating System :: OS Independent',
        'Programming Language :: Python :: 2.7',
        'Programming Language :: Python :: 3.3',
        'Programming Language :: Python :: Implementation :: PyPy',
        'Topic :: Documentation',
        'Topic :: Software Development :: Libraries :: Python Modules',
    ]
)

基本上就是從 setuptools import setup,然後呼叫這個 function。其中 name 是 project 的名字,versionauthorauthor_email 這三個欄位則是直接用剛剛寫在 demo/__init__.py 裡的 metadata。url 是 project 網站首頁的網址。

descriptionlong_description 分別是 project 的簡單與完整描述。long_description 我就直接用 __doc__ 引用寫在檔案開頭的 docstring 了,而這個 docstring 是用 reStructuredText 的格式撰寫的。之所以用這個格式,是因為無論之後是上傳到 PyPI,或是用 docstring 產生文件,系統都是只認這種格式的。(雖然我個人其實比較喜歡 markdown,但也只能將就了。)

packages 代表 project 涵蓋的 packages,這裡直接用 setuptools.find_packages() 來自動列出 package 清單。當然你要寫成 packages=['demo'] 這樣,自己列出所有 packages 也是可以的。

install_requires 代表 project 的 dependencies,列在這裡的 packages 會在你的 project 安裝的時候同時被 pip 安裝進去。這裡我們則是藉助 pip.req.parse_requirements() 來讀出我們寫在 requirements.txt 的 dependencies。同樣的,你想要自己寫成 install_requires=['requests==2.5.1', 'lxml==3.4.1'] 也是可以的。

最後 classifiers 是描述 project 的分類。基本上裡頭列的每一個 classifier 都對應到 PyPI 裡的一個分類,有興趣可以自己到 PyPI 的網站上研究看看。

其餘的參數還有很多,這裡就不一一細講了,要看完整的說明就自己翻文件吧。

MANIFEST.in

呃,因為 setup.py 預設不會把 packages 跟 README 以外的東西發佈出去。所以我們還需要寫個 MANIFEST.in(同樣附上文件),明確指定我們需要 requirements.txt

include requirements.txt  

Step 1-3. Try It Out

到目前為止,目錄應該長這樣:

$ tree
.
├── MANIFEST.in
├── demo
│   ├── __init__.py
│   └── demo.py
├── requirements.txt
└── setup.py

現在用 setup.py 來把 project 安裝進系統吧:

$ python setup.py develop

develop 這個命令會使用 development mode 來安裝你的 project,這可以避免你每次修改 code 都要 re-install 你的 project(詳細的說明請參考這裡)。此外,如同前面所說的,這個指令會一併把 install_requires 列的 dependencies 安裝進環境中:

$ pip list
demo (0.0.1)  
lxml (3.4.1)  
pip (6.0.6)  
requests (2.5.1)  
setuptools (11.0)  

可以看到 requests、lxml 還有我們的 demo 都已經被安裝進我們的環境中了。

然後來試試看剛剛寫的 function:

$ python
Python 2.7.9 (default, Jan  9 2015, 20:15:09)  
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.56)] on darwin
Type "help", "copyright", "credits" or "license" for more information.  
>>> from demo import demo
>>> demo.get_title('http://www.google.com/')
'Google'  
>>> 

Step 2. Testing

Project 寫好之後,我們還想要測試程式的功能是否都正確。

Step 2-1. Writing Tests

Python 的 unit test packages 很多,像官方的 unittestunittest2nosepytest 等等。這裡我們選擇的是 pytest(暫且沒什麼理由,同事最近推薦我用這套,就先用用看了)。

一樣先利用 pip 安裝:

$ pip install pytest

接著在主目錄建立 tests/ 資料夾,project 中的所有 test code 都會放在這裡頭。然後建立一個 test script,叫做 test_demo.py(名稱隨你喜歡,只要 test_ 開頭就可以了)。

tests/test_demo.py

test_demo.py 裡頭,直接用 assert 判斷結果是否正確就可以了。

# -*- coding: utf-8 -*-

from demo import demo


def test_get_title():  
    result = demo.get_title('http://www.google.com/')
    expect = 'Google'
    assert result == expect

由於 pytest 會自己找到 tests/ 底下 test_*.py 裡頭所有 test_ 開頭的 function 作為 test cases(詳細的搜尋規則請參考文件),因此只要執行

$ python -m pytest
=================== test session starts ====================
platform darwin -- Python 2.7.9 -- py-1.4.26 -- pytest-2.6.4  
collected 1 items 

tests/test_demo.py .

================= 1 passed in 0.44 seconds =================

就可以完成所有的測試了。

如果我們故意把答案改成錯的(譬如說 expect = 'google'):

$ python -m pytest
=================== test session starts ====================
platform darwin -- Python 2.7.9 -- py-1.4.26 -- pytest-2.6.4  
collected 1 items 

tests/test_demo.py F

========================= FAILURES =========================
---------------------- test_get_title ----------------------

    def test_get_title():
        result = demo.get_title('http://www.google.com/')
        expect = 'google'
>       assert result == expect
E       assert 'Google' == 'google'  
E         - Google  
E         ? ^  
E         + google  
E         ? ^

tests/test_demo.py:9: AssertionError  
================= 1 failed in 0.38 seconds =================

Step 2-2. Test Coverage

光是測試還不夠,我們還想知道到底我們的測試涵蓋多少程式碼。這時候就需要用到 pytest-cov 這個 pytest 的 plug-in 來幫我們做到這件事:

$ pip install pytest-cov

然後只要在指令後面加上 --cov [MODULE_NAME] 就可以測試 coverage 了:

$ python -m pytest --cov demo
=================== test session starts ====================
platform darwin -- Python 2.7.9 -- py-1.4.26 -- pytest-2.6.4  
plugins: cov  
collected 1 items 

tests/test_demo.py .  
----- coverage: platform darwin, python 2.7.9-final-0 ------
Name            Stmts   Miss  Cover  
-----------------------------------
demo/__init__       5      0   100%  
demo/demo           9      1    89%  
-----------------------------------
TOTAL              14      1    93%

================= 1 passed in 0.87 seconds =================

如果想知道程式有哪幾行沒測到,也可以在指令加上 --cov-report term-missing

$ python -m pytest --cov-report term-missing --cov demo
=================== test session starts ====================
platform darwin -- Python 2.7.9 -- py-1.4.26 -- pytest-2.6.4  
plugins: cov  
collected 1 items 

tests/test_demo.py .  
----- coverage: platform darwin, python 2.7.9-final-0 ------
Name            Stmts   Miss  Cover   Missing  
---------------------------------------------
demo/__init__       5      0   100%  
demo/demo           9      1    89%   16  
---------------------------------------------
TOTAL              14      1    93%

================= 1 passed in 0.91 seconds =================

Step 2-3. Testing Against Multiple Python Versions

測試是寫好了,但我們實際上只確保當前的 Python 版本可以運作。偏偏 Python 還有 Python 2、Python 3 跟 PyPy 等等不同的版本,所以我們需要每次都一個一個手動切換跑測試嗎?

當然不需要,因為我們有 tox

$ pip install tox

安裝完成之後,只要在主目錄下寫個 tox.ini 就可以完成設定。

tox.ini

[tox]
envlist = py27, py34, pypy

[testenv]
deps =  
    -r{toxinidir}/requirements.txt
    pytest
    pytest-cov
commands =  
    python -m pytest --cov-report term-missing --cov demo

我們在 tox.inienvlist 指定了三個測試環境:Python 2.7、Python 3.4 跟 PyPy。接著只要執行 tox,tox 就會自動在當前目錄的 .tox/ 建立多個 virtual environment,並分別在裝完 deps 指定的 dependencies 之後執行 commands 指定的指令。

比較需要一提的是 -r{toxinidir}/requirements.txt 這行。{toxinidir}tox.ini 所在的目錄,整行的意思實際上就是代表去讀 tox.ini 所在目錄的 requirements.txt 內的 dependencies。

於是:

$ tox
GLOB sdist-make: /Users/jason2506/Projects/demo/setup.py  
py27 create: /Users/jason2506/Projects/demo/.tox/py27  
py27 installdeps: -r/Users/jason2506/Projects/demo/requirements.txt, pytest, pytest-cov  
py27 inst: /Users/jason2506/Projects/demo/.tox/dist/demo-0.0.1.zip  
py27 runtests: PYTHONHASHSEED='2621270020'  
py27 runtests: commands[0] | python -m pytest --cov-report term-missing --cov demo  
=================== test session starts ====================
platform darwin -- Python 2.7.9 -- py-1.4.26 -- pytest-2.6.4  
plugins: cov  
collected 1 items 

tests/test_demo.py .  
----- coverage: platform darwin, python 2.7.9-final-0 ------
Name            Stmts   Miss  Cover   Missing  
---------------------------------------------
demo/__init__       5      0   100%  
demo/demo           9      1    89%   16  
---------------------------------------------
TOTAL              14      1    93%

================= 1 passed in 0.75 seconds =================
py34 create: /Users/jason2506/Projects/demo/.tox/py34  
py34 installdeps: -r/Users/jason2506/Projects/demo/requirements.txt, pytest, pytest-cov  
py34 inst: /Users/jason2506/Projects/demo/.tox/dist/demo-0.0.1.zip  
py34 runtests: PYTHONHASHSEED='2621270020'  
py34 runtests: commands[0] | python -m pytest --cov-report term-missing --cov demo  
=================== test session starts ====================
platform darwin -- Python 3.4.2 -- py-1.4.26 -- pytest-2.6.4  
plugins: cov  
collected 1 items 

tests/test_demo.py .  
----- coverage: platform darwin, python 3.4.2-final-0 ------
Name            Stmts   Miss  Cover   Missing  
---------------------------------------------
demo/__init__       5      0   100%  
demo/demo           9      1    89%   16  
---------------------------------------------
TOTAL              14      1    93%

================= 1 passed in 0.77 seconds =================
pypy create: /Users/jason2506/Projects/demo/.tox/pypy  
pypy installdeps: -r/Users/jason2506/Projects/demo/requirements.txt, pytest, pytest-cov  
pypy inst: /Users/jason2506/Projects/demo/.tox/dist/demo-0.0.1.zip  
pypy runtests: PYTHONHASHSEED='2621270020'  
pypy runtests: commands[0] | py.test --cov-report term-missing --cov demo  
=================== test session starts ====================
platform darwin -- Python 2.7.8[pypy-2.4.0-final] -- py-1.4.26 -- pytest-2.6.4  
plugins: cov  
collected 1 items 

tests/test_demo.py .  
----- coverage: platform darwin, python 2.7.8-final-42 -----
Name            Stmts   Miss  Cover   Missing  
---------------------------------------------
demo/__init__       5      0   100%  
demo/demo           9      1    89%   16  
---------------------------------------------
TOTAL              14      1    93%

================= 1 passed in 2.48 seconds =================
------------------------- summary --------------------------
  py27: commands succeeded
  py34: commands succeeded
  pypy: commands succeeded
  congratulations :)

可以看到 tox 在 .tox/ 底下分別為不同的 Python 版本建立獨立的 virtual environment,並在裝完 dependencies 之後自動跑完所有的測試。

Step 2-4. Coverage Threshold

讓我們再龜毛一點,我們希望 coverage 在 95% 以下的話,就讓整個 test fail。

但這裡有個小問題:pytest-cov 雖然有 --cov-min [MIN_COVERAGE] 這個 option 可以做這件事,但在目前的最新穩定版(1.8.1)這個功能還無法使用(根據它 github 的 issue 來看,要到 2.0 版以後才會正式支援)。

沒辦法,我們只好用 coverage 來幫我們做到這件事。由於在執行 py.test --cov 的時候實際上會在主目錄擺一個 .coverage 來存放 coverage 的結果。又因為 pytest-cov 實際上就是建構在 coverage 之上,所以在執行完 py.test --cov 之後,可以直接用 coverage report 來讀取並印出 coverage 的結果:

; tox.ini
; ...
commands =  
    python -m pytest --cov-report "" --cov demo
    coverage report --show-missing --fail-under 95

coverage--show-missing 等同於 pytest-cov 的 --cov-report term-missing,後面的 --fail-under 95 則指定了 test 會在 coverage 小於 95% 的時候 fail。

由於印出 coverage report 這項重責大任我們已經委任給 coverage 了,所以我在 py.test 後面加上 --report "" 以防止這行指令重複印出 report。

$ tox
...
ERROR: InvocationError: '/Users/jason2506/Projects/demo/.tox/py27/bin/coverage report --show-missing --fail-under 95'  
...
ERROR: InvocationError: '/Users/jason2506/Projects/demo/.tox/py34/bin/coverage report --show-missing --fail-under 95'  
...
ERROR: InvocationError: '/Users/jason2506/Projects/demo/.tox/pypy/bin/coverage report --show-missing --fail-under 95'  
------------------------- summary --------------------------
ERROR:   py27: commands failed  
ERROR:   py34: commands failed  
ERROR:   pypy: commands failed  

因為我們的 coverage 只有 93%,所以可以看到我們的測試 fail 了。

如果你想直接用 coverage 來跑 pytest,這樣寫也是可以的:

; tox.ini
; ...

[testenv]
deps =  
    -r{toxinidir}/requirements.txt
    pytest
    coverage
commands =  
    coverage run --source demo -m pytest
    coverage report --show-missing --fail-under 95

這個的執行結果與上面完全相同,但不需要額外安裝 pytest-cov。

Step 2-5. Excluding Code from Coverage

此外,可以在程式中用註解明確指定某個部分不要列入 coverage 的計算:

# demo/demo.py
# ...
if __name__ == '__main__':  # pragma: no cover  
    print(get_title('http://www.google.com/'))

然後再跑一次看看:

$ python -m pytest --cov demo --cov-report term-missing
=================== test session starts ====================
platform darwin -- Python 2.7.9 -- py-1.4.26 -- pytest-2.6.4  
plugins: cov  
collected 1 items 

tests/test_demo.py .  
----- coverage: platform darwin, python 2.7.9-final-0 ------
Name            Stmts   Miss  Cover   Missing  
---------------------------------------------
demo/__init__       5      0   100%  
demo/demo           7      0   100%  
---------------------------------------------
TOTAL              12      0   100%   

================= 1 passed in 0.40 seconds =================
$ tox
...
------------------------- summary --------------------------

  py27: commands succeeded
  py34: commands succeeded
  pypy: commands succeeded
  congratulations :)

可以發現 coverage 變成 100%,也因此可以通過 coverage threshold 的測試了。

但請注意:這個步驟不是用來無條件衝高 coverage 的,而是為了不要讓過多較無意義的程式片段(如上例的 if __name__ == '__main__'、或是 debug 用的 code)拉低整體的 coverage。

Step 2-6. Packaging Tests

前面也提到了,setup.py 預設不會把 packages 跟 README 以外的東西發佈出去。所以我們要在 MANIFEST.in 裡請 setup.py 一併發佈 tox.initests/ 底下所有的 .py 檔:

include requirements.txt

include tox.ini  
recursive-include tests *.py  

此外,我們可以讓 setup.py 支援跑 tox 測試的操作:

# setup.py

# ...

from pip.req import parse_requirements  
from setuptools import setup, find_packages  
from setuptools.command.test import test as TestCommand

# ...

class Tox(TestCommand):

    def finalize_options(self):
        TestCommand.finalize_options(self)
        self.test_args = ['-v', '-epy']
        self.test_suite = True

    def run_tests(self):
        import tox
        tox.cmdline(self.test_args)


setup(  
    # ...
    tests_require=['tox'],
    cmdclass={'test': Tox},
    # ...
)

之後就可以用 setup.pytest 命令來跑 tox 測試了:

$ python setup.py test

到目前為止,檔案結構長這樣(扣掉自動產生的檔案):

$ tree
.
├── MANIFEST.in
├── demo
│   ├── __init__.py
│   └── demo.py
├── requirements.txt
├── setup.py
├── tests
│   └── test_demo.py
└── tox.ini

Step 3. Documentation

程式寫好了,測試也過了,但是說好的文件呢?

這裡我們採用從 docstring 產生文件的方式。而在 Python 社群中,最有名的 document generator 大概非 sphinx 莫屬了。

$ pip install sphinx

Step 3-1. Quickstart

安裝完成之後可以透過 sphinx-quickstart 來自動生成一些基礎的設定檔:

$ sphinx-quickstart

然後按照指示設定就可以了。以下是我自己的設定:

> Root path for the documentation [.]: docs

這個代表我們要把 document 相關的設定擺在 docs/ 這個資料夾。

> Separate source and build directories (y/N) [n]: n

如果這個選項選 y,程式會在 docs/ 底下再創造一個獨立的資料夾擺 document 的原始碼。但因為我們已經把文件放在獨立的 docs/ 資料夾裡了,這裡選 n 也不會讓檔案結構變得太混亂。

> Name prefix for templates and static dir [_]: <ENTER>

templates 跟 static 是用來擺放 document 頁面的 template 跟 CSS/JS 之類的。這個選項只是用來設定這兩個資料夾名稱的 prefix,一般來說維持預設即可。

> Project name: Demo
> Author name(s): Chi-En Wu
> Project version: 0.0.1
> Project release [0.0.1]: <ENTER>

設定 project 的基本資訊。

> Source file suffix [.rst]: <ENTER>
> Name of your master document (without suffix) [index]: <ENTER>

設定 document 原始碼的附檔名,以及 document 主頁的檔案名稱。基本上維持預設即可。

> Do you want to use the epub builder (y/n) [n]: n

設定是否要支援產生 epub 格式的文件。

> autodoc: automatically insert docstrings from modules (y/N) [n]: y

設定是否要利用 docstring 來自動生成文件(請參考 sphinx.ext.autodoc)。基本上我們主要需要的就是這個功能,當然選 y 囉。

> doctest: automatically test code snippets in doctest blocks (y/N) [n]: y

設定是否要測試 docstring 裡頭的 examples(請參考 sphinx.ext.doctest)。

> intersphinx: link between Sphinx documentation of different projects (y/N) [n]: y

設定是否能夠連結不同 project 的 Sphinx 文件(請參考 sphinx.ext.intersphinx)。如果有用到其它 Python project,這個功能很方便。

> todo: write “todo” entries that can be shown or hidden on build (y/N) [n]: y

設定是否要在文件中顯示標為 todo 的項目(請參考 sphinx.ext.todo)。

> coverage: checks for documentation coverage (y/N) [n]: y

設定是否要支援計算 document coverage 的功能(請參考 sphinx.ext.coverage)。

> pngmath: include math, rendered as PNG images (y/N) [n]: n
> mathjax: include math, rendered in the browser by MathJax (y/n) [n]: n

如果要在文件中寫數學式,可以把其中一個改成 y(請參考 Math support in Sphinx)。基本上我都不會用到,所以選 n

> ifconfig: conditional inclusion of content based on config values (y/N) [n]: n

設定是否能夠用 config 變數決定文件是否要不要包含特定內容(請參考 sphinx.ext.ifconfig)。

> viewcode: include links to the source code of documented Python objects (y/n) [n]: y

設定是否能夠在文件中連結 Python 的 source code(請參考 sphinx.ext.viewcode)。

> Create Makefile? (Y/n) [y]: y
> Create Windows command file? (Y/n) [y]: n

是否產生 Makefile 跟 Windows 批次檔。由於我不是用 Windows,直接用 Makefile 來產生 document 就可以了。

完成之後會在主目錄下產生一個 docs/,結構應該長這樣:

$ tree
.
│ ...
├── docs
│   ├── Makefile
│   ├── _build/
│   ├── _static/
│   ├── _templates/
│   ├── conf.py
│   └── index.rst
│ ...

接著對 docs/conf.py 做一點小修改。

首先,為了讓 sphinx 能找到 project 的 source code,我們要將正確的位置加到搜尋路徑中。也就是把

#sys.path.insert(0, os.path.abspath('.'))

改成

sys.path.insert(0, os.path.abspath('..'))  

此外,如果 project 中的不同 module 可能是由不同人撰寫的話,也可以設定為獨立顯示每個 module 的作者:

#show_authors = False

改成

show_authors = True  

Step 3-2. Writing Docstring

現在我們要在原始的程式碼加上 docstring。這些加上去的內容會自動被 sphinx 抓出來編成文件。

demo/__init__.py

# -*- coding: utf-8 -*-
"""An example module.

.. moduleauthor:: Chi-En Wu <jason2506@somewhere.com.tw>

"""

from __future__ import print_function

import requests  
from lxml import html


def get_title(url):  
    """Get the title of a web page.

    :param url: url of web page.
    :type url: str
    :returns: title of web page.
    :rtype: str

    >>> from demo import demo
    >>> demo.get_title('http://www.google.com/')
    'Google'

    .. versionadded:: 0.0.1

    """

    page = requests.get(url)
    root = html.fromstring(page.text)
    return root.findtext('.//title')

如同前面說過的,這裡的 docstring 是用 reStructuredText 的格式撰寫的。parameters 跟 return value 的寫法可以參考這裡

Step 3-3. API Document

接著,這裡要用官方提供的 sphinx-apidoc 來自動產生 API 文件:

$ sphinx-apidoc -o docs/api demo
Creating file docs/api/demo.rst.  
Creating file docs/api/modules.rst.  

可以看到這個命令在 docs/api 目錄下自動為每個 sub-module 產生 .rst 檔。

然後,我們就可以用 Makefile 來產生 HTML document。

$ cd docs
$ make html
sphinx-build -b html -d _build/doctrees   . _build/html  
Making output directory...  
Running Sphinx v1.2.3  
loading pickled environment... done  
building [html]: targets for 3 source files that are out of date  
updating environment: 0 added, 1 changed, 0 removed  
reading sources... [100%] api/demo  
looking for now-outdated files... none found  
pickling environment... done  
checking consistency... /Users/jason2506/Projects/demo/docs/api/modules.rst:: WARNING: document isn't included in any toctree  
done  
preparing documents... done  
writing output... [100%] index  
writing additional files... (1 module code pages) _modules/index genindex py-modindex search  
copying static files... done  
copying extra files... done  
dumping search index... done  
dumping object inventory... done  
build succeeded, 1 warning.

Build finished. The HTML pages are in _build/html.  

docs/ 目錄下所有的 .rst 都會產生一個對應的 HTML 檔案。生成的 HTML document 擺在 _build/html/ 裡頭。

打開 index.html 右上角的 modulesindex 連結之後,點開 demo 這個 module 的頁面應該就可以看到剛剛的註解變成文件了。不過我懶得截圖了,各位看倌就自己編來看看吧(XD)。

但要注意的是,如果有增刪 modules 的話,docs/api 裡不會自動更新。到時候還是要重新用 sphinx-apidoc 來重新產生 *.rst

Step 3-4. Doctest

前面有提到我們可以測試 docstring 裡頭的 examples(>>> 開頭的那幾行)。要用到這項功能請先確定 quickstart 時的 doctest 選項是否有選 y。如果沒有的話,可以修改 docs/conf.py,找到 extensions = [...],並在裡頭加入 'sphinx.ext.doctest'

然後移動到 docs/ 裡打 make doctest 就可以了:

$ cd docs
$ make doctest
sphinx-build -b doctest -d _build/doctrees   . _build/doctest  
Running Sphinx v1.2.3  
loading pickled environment... done  
building [doctest]: targets for 3 source files that are out of date  
updating environment: 0 added, 1 changed, 0 removed  
reading sources... [100%] api/demo  
looking for now-outdated files... none found  
pickling environment... done  
checking consistency... /Users/jason2506/Projects/demo/docs/api/modules.rst:: WARNING: document isn't included in any toctree  
done  
running tests...

Document: api/demo  
------------------
1 items passed all tests:  
   2 tests in default
2 tests in 1 items.  
2 passed and 0 failed.  
Test passed.

Doctest summary  
===============
    2 tests
    0 failures in tests
    0 failures in setup code
    0 failures in cleanup code
build succeeded, 1 warning.  
Testing of doctests in the sources finished, look at the  results in _build/doctest/output.txt.  

這個功能可以用來隨時檢查文件中的 examples 是否正確。

Step 3-5. Documentation Coverage

test code 可以算 coverage,document 當然也可以(XD)。

首先,要先確定 quickstart 時的 coverage 選項是否有選 y。如果沒有的話,可以修改 docs/conf.py,找到 extensions = [...],並在裡頭加入 'sphinx.ext.coverage'

接著修改 docs/Makefile,在最底下加上

coverage:  
     $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
     @echo
     @cat $(BUILDDIR)/coverage/python.txt

然後找到 .PHONY: 這行,在最後面加上 coverage

接著只要 make coverage 就可以看到沒有被列在 document 的 Python objects(classes, methods, functions, ...etc)了:

$ cd docs
$ make coverage
sphinx-build -b coverage -d _build/doctrees   . _build/coverage  
Making output directory...  
Running Sphinx v1.2.3  
loading pickled environment... done  
building [coverage]: coverage overview  
updating environment: 0 added, 0 changed, 0 removed  
looking for now-outdated files... none found  
build succeeded.

Undocumented Python objects  
===========================

注意,Undocumented Python objects 是沒有被列在 document 的 Python objects,不見得是沒有 docstring 的 Python objects。如果有用 :undoc-members: 把沒有 docstring 的 Python objects 加到 document 裡面,還是會被當成 documented Python objects。

Step 3-6. Packaging Docs

同樣的,我們要在 MANIFEST.in 裡請 setup.py 一併發佈 docs/ 底下的 documents:

include requirements.txt

include tox.ini  
recursive-include tests *.py

graft docs  
prune docs/_build  

另外,由於 setuptools 也支援 build_sphinx 這個擴充命令,所以我們也可以加入一個 setup.cfg,把 sphinx 的設定寫進去。

setup.cfg

[build_sphinx]
source-dir = docs  
build-dir  = docs/_build  
all_files  = 1  

之後就可以用 build_sphinx 命令來生成文件了:

$ python setup.py build_sphinx

Step 4. Deployment

費盡千辛萬苦,終於可以把 project 發佈出去了。首先我們要先用 setup.pysdist 命令來包裝整個 project。

$ python setup.py sdist
running sdist  
running egg_info  
writing requirements to demo.egg-info/requires.txt  
writing demo.egg-info/PKG-INFO  
writing top-level names to demo.egg-info/top_level.txt  
writing dependency_links to demo.egg-info/dependency_links.txt  
reading manifest file 'demo.egg-info/SOURCES.txt'  
reading manifest template 'MANIFEST.in'  
writing manifest file 'demo.egg-info/SOURCES.txt'  
warning: sdist: standard file not found: should have one of README, README.rst, README.txt

running check  
creating demo-0.0.1  
creating demo-0.0.1/demo  
creating demo-0.0.1/demo.egg-info  
creating demo-0.0.1/docs  
creating demo-0.0.1/docs/api  
creating demo-0.0.1/tests  
making hard links in demo-0.0.1...  
hard linking MANIFEST.in -> demo-0.0.1  
hard linking requirements.txt -> demo-0.0.1  
hard linking setup.cfg -> demo-0.0.1  
hard linking setup.py -> demo-0.0.1  
hard linking tox.ini -> demo-0.0.1  
hard linking demo/__init__.py -> demo-0.0.1/demo  
hard linking demo/demo.py -> demo-0.0.1/demo  
hard linking demo.egg-info/PKG-INFO -> demo-0.0.1/demo.egg-info  
hard linking demo.egg-info/SOURCES.txt -> demo-0.0.1/demo.egg-info  
hard linking demo.egg-info/dependency_links.txt -> demo-0.0.1/demo.egg-info  
hard linking demo.egg-info/requires.txt -> demo-0.0.1/demo.egg-info  
hard linking demo.egg-info/top_level.txt -> demo-0.0.1/demo.egg-info  
hard linking docs/Makefile -> demo-0.0.1/docs  
hard linking docs/conf.py -> demo-0.0.1/docs  
hard linking docs/index.rst -> demo-0.0.1/docs  
hard linking docs/api/demo.rst -> demo-0.0.1/docs/api  
hard linking docs/api/modules.rst -> demo-0.0.1/docs/api  
hard linking tests/test_demo.py -> demo-0.0.1/tests  
copying setup.cfg -> demo-0.0.1  
Writing demo-0.0.1/setup.cfg  
creating dist  
Creating tar archive  
removing 'demo-0.0.1' (and everything under it)  

可以確認一下是不是 tox.initests/docs/ 之類該包的檔案都包進去了。

都包好之後,就可以自行用

$ python setup.py upload

上傳到官方或是其它自己架的 PyPI 上面。像我們公司用的是 devpi,可以參考這裡

Summary

這篇筆記基本上只是介紹如何從無到有建立一個包含 test code 與 document 的 Python project。由於篇幅已經不短了(再加上我懶得寫,哈哈哈),關於如何寫 unit test 跟 sphinx document 就不在這篇細講。上面的每個部分我都有擺上對應的文件連結,如果想確實把每一個步驟做好,還是建議自己仔細地把相關文件讀完。

File Structure

$ tree
.
├── MANIFEST.in
├── [PACKAGE_NAME]
│   ├── __init__.py
│   └── ...
├── docs
│   ├── Makefile
│   ├── _build/
│   ├── _static/
│   ├── _templates/
│   ├── conf.py
│   └── index.rst
├── requirements.txt
├── setup.cfg
├── setup.py
├── tests
│   ├── test_*.py
│   └── ...
└── tox.ini

Tools

Commands

Further Reading

  • LinkedIn
  • Tumblr
  • Reddit
  • Google+
  • Pinterest
  • Pocket
Comments powered by Disqus