Đóng gói và phát hành package Python lên PyPI

1. Tổng quan

Bạn viết được một module Python hữu ích và muốn người khác cài nó bằng pip install. Muốn vậy, bạn cần đóng gói code thành một distribution rồi đưa lên PyPI (Python Package Index).

Bài này đi qua toàn bộ quy trình. Bạn sẽ dựng cấu trúc project chuẩn, khai báo metadata trong pyproject.toml, build ra wheel và sdist, thử trên TestPyPI, rồi phát hành thật. Cuối bài là cách tự động hóa việc phát hành bằng GitHub Actions với Trusted Publishing.

2. Cấu trúc project

Ví dụ xuyên suốt là thư viện nhỏ nonla-order-tools, dùng để tính tổng tiền đơn hàng. Chúng ta dùng src layout:

nonla-order-tools/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── nonla_order_tools/
│       ├── __init__.py
│       ├── pricing.py
│       └── cli.py
└── tests/
    └── test_pricing.py

Code nằm trong src/ thay vì ngay thư mục gốc. Lợi ích chính là khi chạy test, Python không thể vô tình import code từ thư mục làm việc. Nó buộc phải import bản đã cài, nên bạn phát hiện sớm lỗi quên đóng gói file.

Có hai cái tên cần phân biệt:

  • Tên distribution trên PyPI: nonla-order-tools. Người dùng gõ tên này với pip install.
  • Tên import package: nonla_order_tools. Dùng trong import, không được chứa dấu gạch ngang.

2.1. Code của thư viện

# src/nonla_order_tools/pricing.py
from dataclasses import dataclass, field
from decimal import Decimal


@dataclass(frozen=True)
class OrderLine:
    product: str
    unit_price: Decimal
    quantity: int


@dataclass
class Order:
    customer: str
    lines: list[OrderLine] = field(default_factory=list)

    def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
        if quantity <= 0:
            raise ValueError("quantity must be positive")
        self.lines.append(OrderLine(product, Decimal(unit_price), quantity))


def calculate_total(order: Order, vat_rate: Decimal = Decimal("0.10")) -> Decimal:
    subtotal = sum((l.unit_price * l.quantity for l in order.lines), Decimal("0"))
    return (subtotal * (1 + vat_rate)).quantize(Decimal("1"))

Order chứa danh sách OrderLine. calculate_total cộng tiền từng dòng, nhân thêm VAT (mặc định 10%) rồi làm tròn về đơn vị đồng. Code dùng Decimal thay vì float để tránh sai số khi tính tiền.

# src/nonla_order_tools/__init__.py
from importlib.metadata import version

from .pricing import Order, OrderLine, calculate_total

__all__ = ["Order", "OrderLine", "calculate_total"]
__version__ = version("nonla-order-tools")

__version__ được đọc từ metadata của package đã cài. Nhờ vậy version chỉ khai báo một chỗ duy nhất là pyproject.toml.

Thêm một lệnh dòng lệnh nhỏ:

# src/nonla_order_tools/cli.py
import sys
from decimal import Decimal

from .pricing import Order, calculate_total


def main() -> None:
    # Cách dùng: order-total <giá> <số lượng> [<giá> <số lượng> ...]
    args = sys.argv[1:]
    order = Order(customer="cli")
    for price, qty in zip(args[::2], args[1::2]):
        order.add("item", price, int(qty))
    print(calculate_total(order, Decimal("0.10")))

3. Khai báo metadata trong pyproject.toml

pyproject.toml là nơi duy nhất mô tả cách build và thông tin package. Bạn không cần setup.py cho project mới.

[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"

[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "Tính tổng tiền đơn hàng có VAT"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nón Lá Dev", email = "dev@nonla.example" }]
keywords = ["order", "pricing", "vat"]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
]
dependencies = []

[project.optional-dependencies]
test = ["pytest>=8"]

[project.scripts]
order-total = "nonla_order_tools.cli:main"

Giải thích từng phần:

  • [build-system] cho pip và build biết dùng build backend nào. Ở đây là hatchling. Bạn cũng có thể dùng setuptools, flit-core hoặc pdm-backend. Metadata trong [project] vẫn giữ nguyên.
  • name và version là bắt buộc. Version nên theo dạng MAJOR.MINOR.PATCH.
  • readme sẽ trở thành trang mô tả trên PyPI.
  • requires-python chặn việc cài trên phiên bản Python không hỗ trợ.
  • license = "MIT" là biểu thức SPDX theo chuẩn mới. Chuẩn này cần hatchling>=1.27, nên phiên bản tối thiểu được ghi rõ ở trên.
  • dependencies liệt kê thư viện bắt buộc lúc chạy, ví dụ ["httpx>=0.27"].
  • optional-dependencies tạo “extra”. Người dùng có thể cài bằng pip install "nonla-order-tools[test]".
  • [project.scripts] tạo lệnh order-total trong PATH khi cài package. Lệnh này gọi hàm main trong module cli.

Hatchling tự tìm thấy src/nonla_order_tools vì tên thư mục trùng với tên project đã chuẩn hóa (gạch ngang thành gạch dưới). Nếu tên khác nhau, bạn phải khai báo thủ công:

[tool.hatch.build.targets.wheel]
packages = ["src/nonla_order_tools"]

4. Viết test và cài ở chế độ editable

Trước khi phát hành, hãy chắc chắn hành vi chính đúng:

# tests/test_pricing.py
from decimal import Decimal

import pytest

from nonla_order_tools import Order, calculate_total


def test_total_includes_vat():
    order = Order(customer="Lan")
    order.add("Cà phê", "25000", 2)
    order.add("Bánh mì", "20000")

    assert calculate_total(order) == Decimal("77000")


def test_rejects_non_positive_quantity():
    order = Order(customer="Minh")
    with pytest.raises(ValueError, match="positive"):
        order.add("Trà", "15000", 0)

Test đầu tiên tính (25000 × 2 + 20000) × 1.1 = 77000. Test thứ hai kiểm tra số lượng bằng 0 bị từ chối.

Cài project ở chế độ editable rồi chạy test:

python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -q
..                                                    [100%]
2 passed in 0.02s

Cờ -e liên kết package đã cài với thư mục src/. Bạn sửa code là có hiệu lực ngay, không cần cài lại.

5. Build wheel và sdist

Cài công cụ build rồi chạy nó ở thư mục gốc:

pip install --upgrade build twine
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.27
* Building sdist...
* Building wheel from sdist
Successfully built nonla_order_tools-0.1.0.tar.gz and nonla_order_tools-0.1.0-py3-none-any.whl

Thư mục dist/ giờ chứa hai file:

  • sdist (.tar.gz): mã nguồn kèm metadata. Dùng khi không có wheel phù hợp.
  • wheel (.whl): bản đã build sẵn. Đuôi py3-none-any nghĩa là chạy được trên mọi hệ điều hành với Python 3, vì package là pure Python.

Kiểm tra xem wheel có đủ file không:

python -m zipfile -l dist/nonla_order_tools-0.1.0-py3-none-any.whl
File Name                                             Modified             Size
nonla_order_tools/__init__.py                  2020-02-02 00:00:00          209
nonla_order_tools/cli.py                       2020-02-02 00:00:00          363
nonla_order_tools/pricing.py                   2020-02-02 00:00:00          786
nonla_order_tools-0.1.0.dist-info/METADATA     2020-02-02 00:00:00         1012
nonla_order_tools-0.1.0.dist-info/WHEEL        2020-02-02 00:00:00           87
nonla_order_tools-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00     58
...

Ngày giờ cố định là chủ ý: hatchling tạo reproducible build. Kích thước file trên máy bạn có thể khác một chút.

Tiếp theo, dùng twine kiểm tra metadata, đặc biệt là README có render được không:

twine check dist/*
Checking dist/nonla_order_tools-0.1.0-py3-none-any.whl: PASSED
Checking dist/nonla_order_tools-0.1.0.tar.gz: PASSED

Bước cuối trước khi upload là cài wheel vào một môi trường sạch và thử import:

python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl
/tmp/verify/bin/python -c "import nonla_order_tools as n; print(n.__version__)"
/tmp/verify/bin/order-total 25000 2 20000 1
0.1.0
77000

Nếu bước này lỗi ModuleNotFoundError, nhiều khả năng bạn đã quên đóng gói một file hoặc khai báo sai đường dẫn package.

6. Thử trên TestPyPI trước

TestPyPI là bản sao độc lập của PyPI, dành riêng cho việc thử nghiệm. Hai bên có tài khoản và token riêng.

  1. Tạo tài khoản trên TestPyPI và bật xác thực hai lớp (2FA).
  2. Vào phần Account settings → API tokens và tạo token. Token bắt đầu bằng pypi-.
  3. Upload:
twine upload --repository testpypi dist/*

Khi được hỏi, nhập username là __token__ và password là chuỗi token. Để không phải gõ lại, bạn có thể lưu vào ~/.pypirc:

[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...

[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJ...

Đặt quyền chmod 600 ~/.pypirc để người dùng khác trên máy không đọc được file. Sau đó cài thử từ TestPyPI:

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  nonla-order-tools

Cờ --extra-index-url cho phép pip lấy dependency từ PyPI thật, vì TestPyPI thường không có đủ các package phụ thuộc.

7. Phát hành lên PyPI

Khi mọi thứ ổn, quy trình trên PyPI thật giống hệt, chỉ bỏ --repository:

twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading nonla_order_tools-0.1.0-py3-none-any.whl
Uploading nonla_order_tools-0.1.0.tar.gz
View at:
https://pypi.org/project/nonla-order-tools/0.1.0/

Từ giờ, bất kỳ ai cũng có thể chạy pip install nonla-order-tools.

Với những lần phát hành sau, bạn làm theo các bước:

  1. Tăng version trong pyproject.toml, ví dụ lên 0.1.1.
  2. Xóa thư mục dist/ cũ: rm -rf dist.
  3. Chạy lại python -m build, twine check, rồi twine upload.

Lưu ý: PyPI không cho phép upload lại một file trùng tên version đã tồn tại, kể cả khi bạn đã xóa release đó. Nếu lỡ phát hành bản lỗi, bạn chỉ có thể yank nó và phát hành version mới. Đây là lý do nên luôn thử trên TestPyPI trước.

8. Tự động phát hành với Trusted Publishing

Lưu token dài hạn trong CI tiềm ẩn rủi ro bị lộ. Trusted Publishing loại bỏ hẳn token. PyPI tin tưởng một workflow GitHub Actions cụ thể thông qua OIDC và cấp token ngắn hạn cho mỗi lần chạy.

Thiết lập trên PyPI: vào Publishing của project, hoặc dùng “pending publisher” nếu project chưa tồn tại. Tại đây, khai báo owner, tên repository, tên file workflow publish.yml và environment pypi.

# .github/workflows/publish.yml
name: Publish to PyPI

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python -m pip install build
      - run: python -m build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

Cách workflow hoạt động:

  • Workflow chạy mỗi khi bạn tạo GitHub Release.
  • Job build tạo file trong dist/ và lưu chúng thành artifact.
  • Job publish tách riêng và chỉ có quyền id-token: write. Quyền này cho phép xin token OIDC, và action pypa/gh-action-pypi-publish dùng token đó để upload. Bạn không cần secret nào.
  • Tách hai job giúp bước build, vốn chạy code của bạn và các dependency build, không bao giờ chạm được vào quyền phát hành.

9. Lỗi hay gặp

  • Tên đã bị chiếm: PyPI báo 403 hoặc The name is too similar to an existing project. Hãy kiểm tra tên trước khi đặt, và nên thêm tiền tố riêng như nonla-.
  • File already exists: bạn quên tăng version, hoặc thư mục dist/ còn sót file cũ. Luôn xóa dist/ trước khi build.
  • Thiếu file dữ liệu (JSON, template…): file không nằm trong thư mục package thì không được đóng gói. Hãy đặt chúng trong src/nonla_order_tools/ và đọc bằng importlib.resources.
  • Test pass ở máy nhưng import lỗi sau khi cài: đây chính là lỗi src layout giúp bạn tránh. Luôn thử cài wheel vào venv sạch như ở mục 5.
  • Khai báo dependency quá chặt: thư viện không nên ghim cứng phiên bản như requests==2.32.3, vì sẽ gây xung đột với project của người dùng. Chỉ đặt cận dưới, như requests>=2.31. Ghim cứng chỉ phù hợp cho ứng dụng, và nên làm qua lock file.
  • Lộ token: không bao giờ commit ~/.pypirc hay token vào repository. Nên dùng token giới hạn theo từng project, hoặc tốt hơn là Trusted Publishing.

Lưu ý: Không phải code nào cũng nên lên PyPI. Thư viện nội bộ công ty nên dùng private index (như Artifactory, GitLab Package Registry) hoặc cài trực tiếp từ Git. PyPI là không gian công khai, và tên đã chiếm sẽ rất khó lấy lại.

10. Kết luận

Quy trình đóng gói Python hiện đại khá gọn. Tất cả xoay quanh một file pyproject.toml, một build backend như hatchling, lệnh python -m build để tạo wheel và sdist, cùng twine để kiểm tra và upload.

Bạn nên tạo thói quen làm theo thứ tự: chạy test, build, cài vào venv sạch, thử trên TestPyPI, rồi mới phát hành lên PyPI. Khi project ổn định, hãy chuyển sang Trusted Publishing với GitHub Actions. Mỗi lần phát hành khi đó chỉ còn là tạo một Release, và bạn không phải quản lý token nữa.

pythonpypipackagingpyprojectgithub-actions