--- 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 網站查看建置狀況與結果。