Tạo gói thư viện chuẩn Python

  Apr 18, 2020      2m
   

Python package: A-Z

Tạo gói thư viện chuẩn Python

Một trong những điểm mạnh của Python đó là cài đặt gói thư viện (library / package) vô cùng dễ dàng. Ta thường dùng lệnh sau để cài đặt một gói thư viện mới trong Python:

$ pip install <tên-gói-thư-viện>

Bài viết sau sẽ hướng dẫn bạn các bước viết một gói thư viện chuẩn Python:

  • Khai báo gói thư viện
  • Cấu hình unit test
  • Hiện thực thư viện Python
  • Cấu hình pre-commit
  • Cấu hình CI
  • Viết tài liệu hướng dẫn (documentation)

Gói thư viện mẫu này mình sẽ đặt tên là blank. Toàn bộ source code sẽ được đính kèm ở cuối bài viết.

Khai báo thư viện Python

Code Python của thư viện sẽ nằm trong thư mục blank.

Hai file setup.pysetup.cfg nằm trong cùng cấp với thư mục source code.

Do đó cấu trúc thư mục sẽ trông như sau:

blank
    |- __init__.py
    |- ...
setup.cfg
setup.py

Khai báo thông tin gói thư viện Python trong 2 file: setup.cfgsetup.py.

setup.cfg

[metadata]
name=blank
download_url=https://github.com/minhng-info/blank/tarball/master
description=A blank / template for creating a new Python package
long_description=file:README.md
long_description_content_type=text/markdown
author=minhng-info
author_email=minhng92@gmail.com
maintainer=minhng-info
maintainer_email=minhng92@gmail.com
url=https://github.com/minhng-info/blank
license=MIT
license_files=LICENSE
classifiers=
    Development Status :: 4 - Beta
    License :: OSI Approved :: MIT License
    Topic :: Software Development :: Libraries :: Python Modules
    Intended Audience :: Developers
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    Programming Language :: Python :: Implementation

[options]
packages=
    blank
python_requires=>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4,
#install_requires=
#    <your-dependency-lib-1>
#    <your-dependency-lib-2>

[options.entry_points]
console_scripts =
    hello_blank = blank.main:say_hello

[bdist_wheel]
universal=1

Giải thích một số config:

  • name: tên thư viện / package.
  • download_url: link download code release của thư viện. Nếu release trên Github, định dạng sẽ là: https://github.com/USER/REPOSITORY-NAME/tarball/master. Hoặc thay bằng URL khác tùy bạn.
  • url: URL đến trang chủ của thư viện, ta có thể để đường dẫn của Githbu repository nếu là mã nguồn mở hoặc là trang chủ của thư viện.
  • classifiers: đặc tả một vài thông tin cho thư viện như license, giai đoạn phát triển, chủ đề, version Python hỗ trợ. Tham khảo danh sách đầy đủ tại: https://pypi.org/classifiers/.
  • packages: thư mục chứa source thư viện.
  • python_requires: giới hạn version Python mà thư viện hỗ trợ.
  • install_requires: dependency của thư viện nào vào các thư viện khác. Ví dụ nếu thư viện mình phụ thuộc vào một số thư viện khác (vd: numpy, PyYAML, …) thì mình sẽ thêm hết vào đây. Để lúc cài đặt thư viện mình nó sẽ cài các thư viện phụ thuộc vào máy người dùng, tránh bị lỗi lúc dùng thư viện của mình.
  • console_scripts: nếu ta có nhu cầu cho phép người dùng chạy command line tính năng của gói thư viện chúng ta trên terminal (console) thì đặc tả theo cú pháp: <tên-câu-lệnh-trên-console> = <đường-dẫn-đến-file-python>:<tên-hàm>.

Hiện thực file setup.py:

setup.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import io
import re
from setuptools import setup

PACKAGE_NAME = "blank"

with io.open("%s/__init__.py" % PACKAGE_NAME, "rt", encoding="utf8") as f:
    version = re.search(r"__version__ = \"(.*?)\"", f.read()).group(1)

setup(version=version)

Lưu ý: cần đổi giá trị biến PACKAGE_NAME cho phù hợp.

Cấu hình unit test cho Python

Tạo thư mục tests chứa tất cả unit test của thư viện chúng ta. Các unit test này sẽ tự động được chạy bởi Code Integration (CI) để đảm bảo rằng code mà developer viết phải pass tất cả unit test này. Mục đích chính tránh gây lỗi cho những tính năng cũ, cũng như đảm bảo tính ổn định cho thư viện. Thư viện hỗ trợ unit test mà mình sử dụng là pytest.

Bạn cũng sẽ cần phải tạo 1 thư mục config cho pytest, đó là conftest.py và đặt cùng cấp với thư mục source code blank. File conftest này có 2 mục đích:

  • Cấu hình cho pytest, ở đây mình không có cấu hình gì đặc biệt nên ta để trống
  • Code Python trong thư mục tests có thể import được source code thư viện của chúng ta là blank, nếu ta không có file này thì pytest sẽ không tìm ra thư viện để import.

Lúc này, cấu trúc thư mục ta như sau:

blank
    |- __init__.py
    |- ...
tests
    |- test_blank.py  # <- our unit test
    |- test_say_hello.py
conftest.py
setup.cfg
setup.py

Trên môi trường phát triển, bạn cũng phải cài đặt pytest:

$ pip install pytest
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.6.9, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /home/minh/Documents/GITHUB/python-blank
collected 0 items                                                              

============================ no tests ran in 0.01s =============================

Do chưa có gì cả, nên mình sẽ hiện thực các unit test như các file dưới đây.

  • test_blank.py: kiểm tra hàm reverse trong thư viện chạy đúng hay không. Có đảo chuỗi chính xác kết quả kỳ vọng không.
  • test_say_hello.py: kiểm tra entry point cho console của mình thực thi mà không bị lỗi Exception nào.

test_blank.py

import blank

def test_blank_package():
    my_str = "minhng.info"
    rev_str = blank.reverse(my_str)
    assert rev_str == "ofni.gnhnim"

test_say_hello.py

import blank

def test_say_hello():
    try:
        blank.main.say_hello()
        assert True
    except:
        assert False    

Thử chạy lại lệnh để kiểm tra unit test xem sao:

$ pytest
=========================================== test session starts ===========================================
platform linux -- Python 3.6.9, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /home/minh/Documents/GITHUB/python-blank
collected 2 items                                                                                         

tests/test_blank.py F                                                                               [ 50%]
tests/test_say_hello.py F                                                                           [100%]

================================================ FAILURES =================================================
___________________________________________ test_blank_package ____________________________________________

    def test_blank_package():
        my_str = "minhng.info"
>       rev_str = blank.reverse(my_str)
E       AttributeError: module 'blank' has no attribute 'reverse'

tests/test_blank.py:5: AttributeError
_____________________________________________ test_say_hello ______________________________________________

    def test_say_hello():
        try:
>           blank.main.say_hello()
E           AttributeError: module 'blank' has no attribute 'main'

tests/test_say_hello.py:5: AttributeError

During handling of the above exception, another exception occurred:

    def test_say_hello():
        try:
            blank.main.say_hello()
            assert True
        except:
>           assert False
E           assert False

tests/test_say_hello.py:8: AssertionError
========================================= short test summary info =========================================
FAILED tests/test_blank.py::test_blank_package - AttributeError: module 'blank' has no attribute 'reverse'
FAILED tests/test_say_hello.py::test_say_hello - assert False
============================================ 2 failed in 0.03s ============================================

Pytest sẽ in lỗi đầy màn hình, cái này không sao. Vì ta chưa hiện thực gì ở thư viện nên báo lỗi là đương nhiên. Nếu bạn theo phong cách Test Driven Development (TDD) thì viết test trước khi hiện thực là điều rất bình thường. Thời còn là SV thì sẽ thấy viết test chưa khi hiện thực nó hơi lạ, khác hẳn quy trình mình hay làm bài tập lớn, đó là code xong rồi mới test.

Hiện thực thư viện Python

Mình sẽ hiện thực code thư viện blank để pass cả 2 unit test trên. Hiện thực 3 file trong thư mục blank đó là: __init__.py, blank_func.pymain.py.

__init__.py

from .blank_func import reverse
from . import main

__version__ = "0.1.0"
__all__ = [
    "reverse",
]

Lưu ý: Ta khai báo version của gói thư viện. Khi public cập nhật phiên bản thư viện ta cần nâng cấp đánh số này lên. Version < 1.0 là trong giai đoạn Beta, còn version từ 1.0 trở lên là production - tức đảm bảo tính ổn định.

blank_func.py

def reverse(value):
    if not isinstance(value, str):
        return None
    result = ''
    for i in reversed(range(len(value))):
        result += value[i]
    return result

main.py

def say_hello():
    print("Welcome to Blank package!")

Sau đó check lại unit test.

$ pytest
=========================================== test session starts ===========================================
platform linux -- Python 3.6.9, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /home/minh/Documents/GITHUB/python-blank
collected 2 items                                                                                         

tests/test_blank.py .                                                                               [ 50%]
tests/test_say_hello.py .                                                                           [100%]

============================================ 2 passed in 0.02s ============================================

Oh yes, pass hết test case rồi!

Cấu hình pre-commit cho gói Python

Thư viện mã nguồn mở thường được tham gia phát triển bởi cộng đồng, rất nhiều developer tham gia viết source code. Do đó, ta cần đảm bảo:

  • Mã nguồn cần được format chung theo 1 phong cách nhất định -> dùng Black formatter
  • Mã nguồn phải pass toàn bộ unit test khi push lên remote repo.

Để làm được điều này, ta cần cấu hình pre-commit để hỗ trợ. Cấu trúc thư mục ta sẽ trông như sau:

...
.pre-commit-config.yaml
.flake8
pyproject.toml
...

Cấu hình pre-commit

.pre-commit-config.yaml

repos:
-   repo: https://github.com/pre-commit/mirrors-autopep8
    sha: v1.4.4  # Use the sha / tag you want to point at
    hooks:
    -   id: autopep8
-   repo: https://github.com/ambv/black
    rev: stable
    hooks:
    -   id: black
-   repo: https://github.com/pre-commit/pre-commit-hooks
    sha: v1.2.3
    hooks:
    -   id: check-merge-conflict
    -   id: debug-statements
    -   id: check-yaml
    -   id: end-of-file-fixer
    -   id: fix-encoding-pragma
    -   id: trailing-whitespace
    -   id: flake8
-   repo: local
    hooks:
    -   id: tests
        name: run tests
        entry: pytest ./tests/
        language: system
        files: '^tests/'
        types: [python]

Ở đây mình đã cấu hình cho pre-commit check về: autopep8 / flake8, Black formatter style, thêm encoding, chạy test, …

.flake8

[flake8]
max-line-length = 120
exclude =
  blank/__init__.py

Cấu hình cho flake8 như trên, khi dùng bạn nhớ sửa tên blank lại.

pyproject.toml

[tool.black]
    py36 = true
    include = '\.pyi?$'
    exclude = '''
    /(
        \.git
      | \.hg
      | \.mypy_cache
      | \.tox
      | \.venv
      | _build
      | buck-out
      | build
      | dist
      # The following are specific to Black, you probably don't want those.
      | blib2to3
      | tests/data
    )/

pyproject.toml dùng để cấu hình cho black style.

Hướng dẫn sử dụng pre-commit:

  • Cài đặt pre-commit trên local: pip install pre-commit. Cấu hình pre-commit trên local, chỉ chạy ở lần đầu tiên khi mới kéo repo về máy: pre-commit install. Nếu báo lỗi pre-commit not found thì bạn có thể cài lại pre-commit bằng lệnh sudo snap install pre-commit --classic và thử lại.
  • Sau khi viết code các kiểu thì bạn dùng lệnh git add để thêm file TRƯỚC khi commit.
  • Chạy pre-commit bằng lệnh: pre-commit run -a

Nếu mọi thứ ok bạn có thể commit code lên repo bình thường, không thì pre-commit sẽ báo lỗi format code ở đâu đó. Do pre-commit này mình có cấu hình black formatter tự động, do đó sau khi chạy pre-commit run có thể sẽ có một số file thay đổi. Lúc này bạn cần git add lại và check lại pre-commit cho đến khi ok hết. Khi đó, code gửi lên repo đã được chuẩn hóa!

Lưu ý: Black formatter yêu cầu Python 3.6+, do đó nếu môi trường local không có bạn có thể bỏ nó đi.

Cấu hình Code Integration tự động cho gói Python

CI (Code Integration) ở đây mình sẽ hướng dẫn dùng Github Action. Tool này xuất hiện từ sau khi Microsoft mua lại Github. Nó khá tiện dụng và dễ dùng, không còn phải dùng tool ngoài như Jenkins hay Travis CI.

Github Action cho phép mình cấu hình môi trường CI, và CI Tool này sẽ chạy trên môi trường cloud. Giúp tự động hóa một số công việc như:

  • Automation Test: chạy unit test và report commit đó có pass hết không.
  • Tự động release phiên bản mới lên PyPI => công việc này bạn có thể quyết định tự làm manual (thủ công) hoặc dùng CI tự động như bài viết.
  • Cấu hình điều kiện khi nào sẽ trigger các action này, ví dụ như trên nhánh release thì trigger action A, trên nhánh master thì trigger action B, hoặc miễn có push là trigger action C.

Cấu hình Github nằm trong thư mục .github:

...
requirements.txt
.github
    |- workflows
        |- pythonpackage.yml
        |- pythonpublish.yml
...

requirements.txt: file này cần thiết để cài đặt môi trường trên CI.

setuptools
pytest
wheel
twine

.github/workflows/pythonpackage.yml

name: Python package

on: [push]

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      max-parallel: 4
      matrix:
        python-version: [2.7, 3.5, 3.6, 3.7]

    steps:
    - uses: actions/checkout@v1
    - name: Set up Python $
      uses: actions/setup-python@v1
      with:
        python-version: $
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
    - name: Lint with flake8
      run: |
        pip install flake8
        # stop the build if there are Python syntax errors or undefined names
        flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
        flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
    - name: Test with pytest
      run: |
        pip install pytest
        pip install pytest-cov
        pytest --cov=python_benchmark_thread_vs_process --cov-report=xml -v
    #- name: Upload coverage to Codecov
    #  uses: codecov/codecov-action@v1
    #  with:
    #    token: $
    #    file: ./coverage.xml
    #    flags: unittests
    #    name: codecov-umbrella
    #    #yml: ./codecov.yml

.github/workflows/pythonpublish.yml

name: Publish package to PyPI

on:
  push:
    branches:
      - release

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - name: Set up Python
      uses: actions/setup-python@v1
      with:
        python-version: '3.x'
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install setuptools wheel twine
    - name: Build and publish
      env:
        TWINE_USERNAME: $
        TWINE_PASSWORD: $
      run: |
        python setup.py sdist bdist_wheel
        twine upload dist/*

Thư viện của mình sẽ được publish lên PyPI khi nào có commit mới ở nhánh release (xem cấu hình trên @ on/push/branches/release). Bạn cũng có thể đọc thêm tài liệu hướng dẫn của Github Action để chỉnh sửa các file cấu hình này.

Lưu ý: hai biến PYPI_USERNAMEPYPI_PASSWORD được cấu hình tại: Settings / Secrets. Lúc này bạn có thể nhấn "Add a new secret" để tiến hành tạo các biến môi trường trên repo Github. Thông tin PYPI_USERNAMEPYPI_PASSWORD bạn phải tạo tài khoản của mình trên web PyPI.

Sau khi push các cấu hình repo này lên Github, bạn có thể kiểm tra các workflow của mình trong tab Actions.

Viết tài liệu cho thư viện - documentation

Ở bước này, ta cần viết hướng dẫn sử dụng cho gói thư viện của mình tại file README.md. Bạn nên viết càng chi tiết càng tốt để có nhiều người dễ tiếp cận sử dụng hơn. Readme này được viết theo định dạng Markdown.

Tham khảo syntax cách viết documentation theo markdown tại: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet

Có một số tool, thư viện hỗ trợ việc viết documentation này hoàn toàn miễn phí như sphinx. Bài viết đã khá dài rồi, bạn có thể tự mày mò tìm hiểu thêm tool khác.

.gitignore cho Python

File .gitignore sau tương đối đầy đủ, nó sẽ bỏ qua một số file sinh ra trong quá trình chạy, giúp bạn tránh git add phải chúng.

https://github.com/minhng-info/python-blank/blob/master/.gitignore

Kiểm tra lại gói thư viện Python trên PyPI

Kiểm tra lại gói blank của chúng ta nhé.

$ pip install blank
$ hello_blank
Welcome to Blank package!
$ python
Python 3.5.6 |Anaconda, Inc.| (default, Aug 26 2018, 21:41:56)
[GCC 7.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import blank
>>> print(blank.reverse("minh"))
hnim

Vậy là ta đã viết thành công một gói thư viện trong Python chuẩn theo phong cách mã nguồn mở!

Các bạn cũng có thể lấy source code blank làm template để chỉnh sửa lại khi bắt đầu viết mới gói thư viện cho riêng mình.


Bài viết về Python:

Tham gia ngay group trên Facebook để cùng thảo luận với đồng bọn nhé: