Try   HackMD

由淺入深 Python Packaging

GitHub: https://github.com/celine-yeh/celine-simplerequest

Python Packaging 介紹

Python packaging from ssuser6f2e1c

Package distribution

我們開發時經常會使用別人開發的套件,透過 pip 來下載,而今天我們開發了一個套件,只要將套件打包發布到 PyPI 或 TestPyPI 就能讓所有人都可以用 pip 來下載和安裝你的套件。

PyPI 為 Python Package Index 的簡寫,是 Python 的第三方套件集中地,pip 能夠利用簡單的指令和步驟幫助我們從 PyPI 上下載、安裝套件。
TestPyPI 可以視為 PyPI 的測試區,他與 PyPI 是分開的,不用擔心會相互影響。

而一個套件主要有三個步驟:Configure、Package、Distribute。

  1. Configure:配置你的套件,包括文件與目錄的組織、打包參數的資訊等,基本的目錄組織如下:

    ​​​package/
    ​​​├── README.md
    ​​​├── pkg/
    ​​​│   └── __init__.py
    ​​​└── setup.py
    

    setup.py

    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.

    一個套件會包含很多的資訊,例如名字、版本等等。而此文件就是用來設定、提供這些資訊的。執行該檔案可以進行安裝或打包。

    • 安裝一個套件到環境中。

      ​​​​​$ python setup.py install
      
    • 將套件打包。

      ​​​​​$ 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 的檔案。

    ​​​$ python setup.py sdist
    ​​​$ ls dist
    ​​​package-*.tar.gz
    

    Built Distributions

    包含了檔案和 metadata,只需要移到目標系統的正確位置下,就能夠安裝,安裝流程得到簡化,使用者只需要下載文件,解壓到特定目錄即可。編譯建構是由發行者來完成,且只需完成一次。

    而 Binary Distributions 是 Built Distributions 的一種,包括了 Egg 和 Wheel。二進制發布格式不用每次都在终端重新編譯生成,採用預編譯格式,安裝速度較快,而 sdist 再安裝前則需要多一個建置的步驟。

    Egg

    Python 第一個主流的打包格式。透過 easy_install 安裝。包含了 .pyc 檔案。

    ​​​$ python setup.py bdist_egg
    ​​​$ ls dist
    ​​​package-*.egg
    
    Wheel

    為了替代 Egg 而出現。可以透過 pip 安裝。
    不包含 .pyc 檔案,預編譯的 .pyc 檔案偶爾會導致各種奇怪的問題,我們更希望他能在每次安裝時在本地生成更新。
    擁有更豐富的軟體包元資訊,甚至包中的每個檔案都有版本記錄。

    ​​​$ python setup.py bdist_wheel
    ​​​$ ls dist
    ​​​package-*.whl
    
    Egg and Wheel

    whl 文件有一點與 egg 文件相似,實際上它們都是偽裝的 zip 文件。如果你將 whl 文件名擴展改為 zip,你就可以使用你的 zip 應用程式打開它,並且可以查看它包含的文件和文件夾。兩者都是單安裝文件,安裝程序會自動幫忙安裝依賴包。

    wheel 是一個安裝包,内含構建的產物,而 egg 則是可移植的包,需要在目標機器上構建產物。

  3. Distribute
    申請 TestPyPIPyPI 帳號,建置 ~/.pypirc 用來設定登入資訊的設定檔。

    ​​​[distutils]
    ​​​index-servers =
    ​​​    testpypi
    
    ​​​[testpypi]
    ​​​repository = https://test.pypi.org/legacy/
    ​​​username = <your username>
    ​​​password = <your password>
    

    將 dist/ 下的打包檔上傳至 TestPyPI 。

    ​​​$ python setup.py sdist upload -r testpypi
    

<yourname>-simplerequest 為例實作

  1. 建立並啟動虛擬環境。

    ​​​$ 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 檔,更多的參數可以參考官方文件

    ​​​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: Upload failed (400): File already exists. See https://test.pypi.org/help/#file-name-reuse
    

    本地測試

    1. 安裝 setup.py

      ​​​​​​$ python setup.py install
      
    2. 安裝成功後,測試套件是否可以運作。

      ​​​​​​>>> import simplehttp
      ​​​​​​>>> ...
      

    打包上傳

    1. 打包成 .whl.tar.gz 檔至 dist/

      ​​​​​​$ python setup.py sdist bdist_wheel --universal
      
    2. 上傳前若還沒設置 ~/.pypirc ,需先建置 ~/.pypirc 用來設定登入資訊的設定檔。

      ​​​​​​[distutils]
      ​​​​​​index-servers =
      ​​​​​​    testpypi
      
      ​​​​​​[testpypi]
      ​​​​​​repository = https://test.pypi.org/legacy/
      ​​​​​​username = <your username>
      ​​​​​​password = <your password>
      
    3. 打包上傳至 TestPyPI 。

      ​​​​​​$ python setup.py sdist bdist_wheel --universal upload -r testpypi
      

套件內容撰寫

get_json()

  1. 這裡要求套件只能使用 Python Standard Library,不能調用其他第三方套件。第一項要求的功能是:

    ​​​>>> 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 。

    ​​​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

    ​​​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 )。

    ​​​$ python -m unittest discover
    

    最後出現 OK 即為單元測試成功。

  4. 此時若執行打包,會發現 find_packages 會將 tests 也視為一個 package 一同打包進去,因此需要修改 setup.py ,排除掉 tests 。

    ​​​packages=find_packages(exclude=['tests'])
    

post_json()

  1. 第二項功能需求:

    ​​​>>> 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
    
  2. 修改 simplehttp/__init__.py ,新增函式 post_json() 。

    ​​​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') 可以參考此文章, Python3 中不能提交 str 類型,需為 bytes 類型。

  3. 修改 tests/test_simplehttp.py ,新增單元測試。

    ​​​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. 有另外一項需求是參數包含中文。

    ​​​>>> 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

    ​​​#/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 會出現錯誤。

    ​​​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 編碼。

    ​​​if args.setdefault('params', {}):
    ​​​     args['params'] = {k: v.encode('utf-8') for k, v in args['params'].items()}
    ​​​     ...
    
  4. 再新增一項 TestCase 至 tests/test_simplehttp.py

    ​​​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. 新的功能需要顯示出自定義的異常。

    ​​​>>> 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. 可以先看這兩篇文章:自訂例外例外兼容 Python2 與 Python3 的寫法

  3. 上面 simplehttp.HttpError: HTTP Status Code: 400 這段可以拆成三個部分:
    [package name].[exception class]: [exception str]

  4. 修改 simplehttp/__init__.py ,再 import 下方輸入程式碼,另外再新增一個 Class 繼承 Exception 。
    上半段的 if else 函式是例外兼容 Python2 與 Python3 的寫法

    ​​​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. 修改函式,發生錯誤時呼叫異常。

    ​​​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

    ​​​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 就可以用來說明。

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.
    '''
    ...

如此一來使用者可以透過呼叫來查看說明文件。

>>> print(simplehttp.get_json.__doc__)

    To get url's response by GET method.

    Args:
    ...

Travis CI

每次更新程式碼時,都要先本地測試,之後提交到 github ,然後再打包發佈到 PyPI ,過程相當繁瑣,而 github + travis-ci 可以解決這個問題,建構一個自動部署環境。

  1. 先進入 travis-ci 網站,可以直接以 Github 登入,並至 travis-ci.com/profile 激活 repo 。

  2. 在專案資料夾下,新增 .travis.yml 檔,參考 Building a Python ProjectPyPI deployment

    ​​​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

  4. 之後 push 到 github 時,都可以進入 Travis CI 網站查看建置狀況與結果。