---
tags: Python
---
# 由淺入深 Python Packaging
[TOC]
GitHub: https://github.com/celine-yeh/celine-simplerequest
## Python Packaging 介紹
{%slideshare ssuser6f2e1c/python-packaging-145439804 %}
### Package distribution
我們開發時經常會使用別人開發的套件,透過 pip 來下載,而今天我們開發了一個套件,只要將套件打包發布到 PyPI 或 TestPyPI 就能讓所有人都可以用 pip 來下載和安裝你的套件。
> [PyPI](https://pypi.org/) 為 Python Package Index 的簡寫,是 Python 的第三方套件集中地,pip 能夠利用簡單的指令和步驟幫助我們從 PyPI 上下載、安裝套件。
[TestPyPI](https://packaging.python.org/guides/using-testpypi/) 可以視為 PyPI 的測試區,他與 PyPI 是分開的,不用擔心會相互影響。
而一個套件主要有三個步驟:Configure、Package、Distribute。
1. **Configure**:配置你的套件,包括文件與目錄的組織、打包參數的資訊等,基本的目錄組織如下:
```
package/
├── README.md
├── pkg/
│ └── __init__.py
└── setup.py
```
#### [setup.py](https://docs.python.org/2.7/distutils/setupscript.html)
`setup.py` is the build script for setuptools. It tells setuptools about your package (such as the name and version) as well as which code files to include.
一個套件會包含很多的資訊,例如名字、版本等等。而此文件就是用來設定、提供這些資訊的。執行該檔案可以進行安裝或打包。
* 安裝一個套件到環境中。
```cmd
$ python setup.py install
```
* 將套件打包。
```cmd
$ python setup.py sdist
```
而 `setup.py` 是用以下 distutils 或 setuptools 這兩種打包工具的功能寫成。
#### distutils
是 Python Standard Library 中負責建立 Python 第三方庫的安裝器,能夠進行 Python 模組的安裝和發布。distutils 對於簡單的發布很有用,但功能缺少。大部分會使用更便利的 setuptools 。
#### setuptools
是 **distutils 的加強版**,不包括在 Python Standard Library 中。
支援了 Egg 與 Wheel 打包格式,也支援 install_requires 依賴包、find_packages 自動搜索包等功能。
最大的優勢是它在包管理能力方面的增強。它可以使用一種更加透明的方法來查詢、下載並安裝依賴包,並可以在一個包的多個版本中自由進行切換,這些版本都安裝在同一個系統上。
2. **Package**:就像是我們要傳一整個資料夾給別人時,我們通常會壓縮成一個壓縮檔,方便傳輸,而套件亦同,我們需要先打包成一個檔案,通常有下列幾種格式。
#### Source Distributions
簡寫為 sdist,即源碼包,基本上就是你寫的所有程式碼與檔案,但不包括 .pyc 。打包後會在 dist/ 下看到 .tar.gz 的檔案。
```cmd
$ python setup.py sdist
$ ls dist
package-*.tar.gz
```
#### Built Distributions
包含了檔案和 metadata,只需要移到目標系統的正確位置下,就能夠安裝,安裝流程得到簡化,使用者只需要下載文件,解壓到特定目錄即可。編譯建構是由發行者來完成,且只需完成一次。
而 Binary Distributions 是 Built Distributions 的一種,包括了 Egg 和 Wheel。二進制發布格式不用每次都在终端重新編譯生成,採用預編譯格式,安裝速度較快,而 sdist 再安裝前則需要多一個建置的步驟。
##### [Egg](http://peak.telecommunity.com/DevCenter/PythonEggs)
Python 第一個主流的打包格式。透過 easy_install 安裝。包含了 .pyc 檔案。
```cmd
$ python setup.py bdist_egg
$ ls dist
package-*.egg
```
##### Wheel
為了替代 Egg 而出現。可以透過 pip 安裝。
不包含 .pyc 檔案,預編譯的 .pyc 檔案偶爾會導致各種奇怪的問題,我們更希望他能在每次安裝時在本地生成更新。
擁有更豐富的軟體包元資訊,甚至包中的每個檔案都有版本記錄。
```cmd
$ python setup.py bdist_wheel
$ ls dist
package-*.whl
```
##### Egg and Wheel
whl 文件有一點與 egg 文件相似,實際上它們都是**偽裝的** zip 文件。如果你將 whl 文件名擴展改為 zip,你就可以使用你的 zip 應用程式打開它,並且可以查看它包含的文件和文件夾。兩者都是單安裝文件,安裝程序會自動幫忙安裝依賴包。
wheel 是一個安裝包,内含構建的產物,而 egg 則是可移植的包,需要在目標機器上構建產物。
3. **Distribute**:
申請 [TestPyPI](https://test.pypi.org/) 或 [PyPI](https://pypi.org/) 帳號,建置 [~/.pypirc](https://docs.python.org/2/distutils/packageindex.html#the-pypirc-file) 用來設定登入資訊的設定檔。
```
[distutils]
index-servers =
testpypi
[testpypi]
repository = https://test.pypi.org/legacy/
username = <your username>
password = <your password>
```
將 dist/ 下的打包檔上傳至 TestPyPI 。
```cmd
$ python setup.py sdist upload -r testpypi
```
## 以 [<yourname>-simplerequest](https://github.com/celine-yeh/celine-simplerequest) 為例實作
1. 建立並啟動虛擬環境。
```cmd
$ virtualenv venv
$ source venv/bin/activate
```
2. 建出以下檔案。
```
<yourname>-simplerequest/
├── README.md
├── simplehttp/
│ └── __init__.py
└── setup.py
```
`simplehttp/` 套件的部分說明,一個 python 檔案表示一個模組,而一個包含 `__init__` 的資料夾就可以視為一個套件,外部的使用者可以透過 `import simplehttp` 來使用,若 `simplehttp/` 下還有其他模組,如:
```
simplehttp/
├── __init__.py
└── mod.py
```
則可以透過 `simplehttp.mod` 來呼叫。
3. 設定 `setup.py` 檔,更多的參數可以參考[官方文件](https://docs.python.org/2.7/distutils/setupscript.html#additional-meta-data)。
```python
from setuptools import setup, find_packages
setup(
name='<yourname>-simplerequest',
version='1.0.0',
author='<your name>',
author_email='<your email>',
description='A simple request.',
url='<your github repo or other website>',
packages=find_packages(),
classifiers=[
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7'
],
)
```
特別說明 `packages` 的參數,表示要打包的套件有哪些,可以以陣列列舉或是使用 find_packages 來自動抓取所有 packages 。
而 `classifiers` 內可以放入一些資訊,包括了支援的版本。
4. 此時已經可以將套件打包上傳至 TestPyPI ,要特別注意的是,一但上傳該套件,則此套件此版本不可再上傳,會出現命名重複的錯誤,因次在每次上傳前,強烈建議先在本地安裝測試過再行上傳。
```error
error: Upload failed (400): File already exists. See https://test.pypi.org/help/#file-name-reuse
```
#### 本地測試
1. 安裝 `setup.py` 。
```cmd
$ python setup.py install
```
2. 安裝成功後,測試套件是否可以運作。
```python
>>> import simplehttp
>>> ...
```
#### 打包上傳
1. 打包成 `.whl` 與 `.tar.gz` 檔至 `dist/`
```cmd
$ python setup.py sdist bdist_wheel --universal
```
2. 上傳前若還沒設置 [~/.pypirc](https://docs.python.org/2/distutils/packageindex.html#the-pypirc-file) ,需先建置 [~/.pypirc](https://docs.python.org/2/distutils/packageindex.html#the-pypirc-file) 用來設定登入資訊的設定檔。
```rc
[distutils]
index-servers =
testpypi
[testpypi]
repository = https://test.pypi.org/legacy/
username = <your username>
password = <your password>
```
3. 打包上傳至 TestPyPI 。
```cmd
$ python setup.py sdist bdist_wheel --universal upload -r testpypi
```
## 套件內容撰寫
### get_json()
1. 這裡要求套件只能使用 Python Standard Library,不能調用其他第三方套件。第一項要求的功能是:
```python
>>> import simplehttp
>>> r = simplehttp.get_json('https://httpbin.org/get')
>>> assert r['args'] == {}
>>> params = {'name': 'Celine'}
>>> r = simplehttp.get_json('https://httpbin.org/get?debug=true', params=params)
>>> assert r['args'] == {'debug': 'true', 'name': 'Celine'}
```
2. 編輯 `__init__.py` ,上面 import 的部分,因為 urllib 在 Python2 和 3 之間的使用方法不同,所以需要分開 import 。
```python
import json
try:
import urllib.parse as urllib
except ImportError:
import urllib as urllib
try:
import urllib.request as urlrequest
except ImportError:
import urllib2 as urlrequest
def get_json(url, **args):
if args.setdefault('params', {}):
url += '&' if '?' in url else '?'
url += urllib.urlencode(args['params'])
req = urlrequest.Request(url)
res = urlrequest.urlopen(req)
info = json.loads(res.read())
return info
```
### Unittest
撰寫單元測試 unittest ,方便日後程式碼的維護開發。
1. 在專案資料夾下新增檔案。
```
tests/
├── __init__.py
└── test_simplehttp.py
```
2. 編輯 `test_simplehttp.py` 。
```python
import unittest
import sys
import simplehttp
class GetJsonTest(unittest.TestCase):
def test_url(self):
r = simplehttp.get_json('https://httpbin.org/get')
self.assertEqual(r['args'], {})
def test_url_with_params(self):
r = simplehttp.get_json('https://httpbin.org/get?debug=true')
self.assertEqual(r['args'], {'debug': 'true'})
def test_url_with_other_params(self):
params = {'name': 'Celine'}
r = simplehttp.get_json('https://httpbin.org/get?debug=true', params=params)
self.assertEqual(r['args'], {'debug': 'true', 'name': 'Celine'})
```
3. 此時可以執行測試查看結果,自動找出資料夾底下所有的測試(預設會找 `test*.py` )。
```cmd
$ python -m unittest discover
```
最後出現 OK 即為單元測試成功。
4. 此時若執行打包,會發現 find_packages 會將 tests 也視為一個 package 一同打包進去,因此需要修改 `setup.py` ,排除掉 tests 。
```python
packages=find_packages(exclude=['tests'])
```
### post_json()
1. 第二項功能需求:
```python
>>> params = {'debug': 'true'}
>>> data = {'isbn': '9789863479116', 'name': 'Celine'}
>>> r = simplehttp.post_json('https://httpbin.org/post', params=params, data=data)
>>> assert r['args'] == params
>>> assert r['json'] == data
```
3. 修改 `simplehttp/__init__.py` ,新增函式 post_json() 。
```python
def post_json(url, **args):
if args.setdefault('params', {}):
url += '&' if '?' in url else '?'
url += urllib.urlencode(args['params'])
headers = {'Content-Type': 'application/json'}
data = json.dumps(args.setdefault('data', {})).encode('utf-8')
req = urlrequest.Request(url=url, data=data, headers=headers)
res = urlrequest.urlopen(req)
info = json.loads(res.read())
return info
```
其中 `encode('utf-8')` 可以參考[此文章](https://blog.csdn.net/IMW_MG/article/details/78555375), Python3 中不能提交 str 類型,需為 bytes 類型。
3. 修改 `tests/test_simplehttp.py` ,新增單元測試。
```python
class PostJsonTest(unittest.TestCase):
def test_url_with_params(self):
params = {'debug': 'true'}
r = simplehttp.post_json('https://httpbin.org/post', params=params)
self.assertEqual(r['args'], params)
def test_url_with_params_and_data(self):
params = {'debug': 'true'}
data = {'isbn': '9789863479116', 'name': 'Celine'}
r = simplehttp.post_json('https://httpbin.org/post', params=params, data=data)
self.assertEqual(r['args'], params)
self.assertEqual(r['json'], data)
```
### 解決中文
1. 有另外一項需求是參數包含中文。
```python
>>> params = {'name': u'常見問題 Q&A'}
>>> r = simplehttp.get_json('https://httpbin.org/get', params=params)
>>> assert r['args'] == params
```
2. 先新增這項 TestCase 至 `tests/test_simplehttp.py`。
```python
#/usr/bin/env python
# -*- coding: UTF-8 -*-
...
class GetJsonTest(unittest.TestCase):
...
def test_url_with_params_in_chinese(self):
params = {'name': u'常見問題 Q&A'}
r = simplehttp.get_json('https://httpbin.org/get', params=params)
self.assertEqual(r['args'], params)
```
此時執行 python2.7 會出現錯誤。
```error
UnicodeEncodeError: 'ascii' code can't encode characters in position 0-3: ordinal not in range(128)
```
3. 修改 `simplehttp/__init__.py` ,get_json() 和 post_json() 都需修改,將 unicode 進行 utf-8 編碼。
```python
if args.setdefault('params', {}):
args['params'] = {k: v.encode('utf-8') for k, v in args['params'].items()}
...
```
4. 再新增一項 TestCase 至 `tests/test_simplehttp.py`。
```python
class PostJsonTest(unittest.TestCase):
...
def test_url_with_data_in_chinese(self):
data = {'isbn': '9789863479116', 'title': u'流暢的 Python'}
r = simplehttp.post_json('https://httpbin.org/post', data=data)
self.assertEqual(r['json'], data)
```
### 自定義異常處理
1. 新的功能需要顯示出自定義的異常。
```python
>>> simplehttp.get_json('https://httpbin.org/status/400')
Traceback (most recent call last):
...
simplehttp.HttpError: HTTP Status Code: 400
>>> import sys
>>> assert sys.last_value.status_code == 400
```
2. 可以先看這兩篇文章:[自訂例外](https://openhome.cc/Gossip/Python/UserDefinedException.html)、[例外兼容 Python2 與 Python3 的寫法](https://mozillazg.com/2016/08/python-the-right-way-to-catch-exception-then-reraise-another-exception.html#hidpython-2-python-3)。
3. 上面 `simplehttp.HttpError: HTTP Status Code: 400` 這段可以拆成三個部分:
`[package name].[exception class]: [exception str]`。
4. 修改 `simplehttp/__init__.py` ,再 import 下方輸入程式碼,另外再新增一個 Class 繼承 Exception 。
上半段的 if else 函式是[例外兼容 Python2 與 Python3 的寫法](https://mozillazg.com/2016/08/python-the-right-way-to-catch-exception-then-reraise-another-exception.html#hidpython-2-python-3)。
```python
if sys.version_info[0] == 3:
def reraise(tp, value, tb=None):
if value.__traceback__ is not tb:
raise value.with_traceback(tb)
else:
raise value
else:
exec('''def reraise(tp, value, tb=None):
raise tp, value, tb
''')
class HttpError(Exception):
def __init__(self, status_code):
self.status_code = status_code
def __str__(self):
return 'HTTP Status Code: %s' % self.status_code
```
5. 修改函式,發生錯誤時呼叫異常。
```python
def get_json(url, **args):
...
req = urlrequest.Request(url)
try:
res = urlrequest.urlopen(req)
info = json.loads(res.read())
except urlrequest.HTTPError as e:
reraise(HttpError, HttpError(err.code), sys.exc_info()[2])
return info
```
6. 新增一項 TestCase 至 `tests/test_simplehttp.py`。
```python
class HttpErrorTest(unittest.TestCase):
def test_http_error_400(self):
try:
simplehttp.get_json('https://httpbin.org/status/400')
except Exception as err:
self.assertEqual(type(err), simplehttp.HttpError)
self.assertEqual(sys.exc_info()[1].status_code, 400)
else:
self.fail('HttpError not raised.')
```
### DocString
別人使用我們的套件時,並不清楚每一個函式的用途,以及需要的參數有哪些,因此加入 DocString 就可以用來說明。
```python
def get_json(url, **args):
'''
To get url's response by GET method.
Args:
url (string): The target url.
**args:
params (dict): The parameters of GET method.
Returns:
info (dict): Convert url's response to dict.
Raises:
HttpError: Can't open url.
'''
...
```
如此一來使用者可以透過呼叫來查看說明文件。
```python
>>> print(simplehttp.get_json.__doc__)
To get url's response by GET method.
Args:
...
```
### Travis CI
每次更新程式碼時,都要先本地測試,之後提交到 github ,然後再打包發佈到 PyPI ,過程相當繁瑣,而 github + travis-ci 可以解決這個問題,建構一個自動部署環境。
1. 先進入 [travis-ci](https://travis-ci.com/) 網站,可以直接以 Github 登入,並至 [travis-ci.com/profile](travis-ci.com/profile) 激活 repo 。
2. 在專案資料夾下,新增 `.travis.yml` 檔,參考 [Building a Python Project](https://docs.travis-ci.com/user/languages/python/) 與 [PyPI deployment](https://docs.travis-ci.com/user/deployment/pypi/)。
```
language: python
python:
- '2.7'
- '3.6'
- '3.7'
dist: xenial
script: python -m unittest discover
deploy:
provider: pypi
server: https://testpypi.python.org/pypi
distributions: 'sdist bdist_wheel'
skip_existing: true
on:
tags: true
user: <your account>
```
其中 `on: tags: true` 內的句子表示:只有在 Github 發布 Release 版本時才會進行上傳動作。
3. 上方的 deploy 少了 password 的原因是:若直接將密碼打在檔案中十分危險,因此需要用加密,加密的方法可以參考 [PyPI deployment / Travis CI](https://docs.travis-ci.com/user/deployment/pypi/)。
4. 之後 push 到 github 時,都可以進入 Travis CI 網站查看建置狀況與結果。