← Lập trình Python nâng cao

Bài 8 · Nâng cao · 26 phút· Cập nhật 11/06/2026

Đóng gói thư viện (packaging)

Biên soạn bởi Nguyễn Anh Tuấn

Module/package/distribution; bố cục src/, pyproject.toml, py.typed, semantic version; build & phát hành lên PyPI bằng uv; dùng thư viện & ràng buộc phiên bản.

Tới giờ code của bạn nằm trong vài file .py. Để tái dùngchia sẻ như dân chuyên nghiệp, ta đóng nó thành thư viện. Trước hết, phân biệt ba khái niệm hay bị lẫn:

  • module = MỘT file .py (vd calc.py → import calc).
  • package = một THƯ MỤC có __init__.py, gom nhiều module (mylib/ → import mylib.core).
  • distribution (“gói” phát hành) = đơn vị bạn CÀI/CHIA SẺ - wheel/sdist đăng trên PyPI.

Một điểm bẫy người mới: tên trên PyPI (để uv add) thường khác tên khi import. PyPI chuẩn hoá tên theo PEP 503; còn tên import lại dùng dấu gạch dưới. Thử ngay ở Trình khám phá đóng gói (đổi tên gói để thấy):

Tên PyPI (chuẩn hoá)
flask-sqlalchemy
Tên import
import flask_sqlalchemy
Phiên bản (PEP 440)
✓ hợp lệ

pyproject.toml (sinh tự động)

[project]
name = "flask-sqlalchemy"
version = "0.1.0"
description = "Thư viện ví dụ"
requires-python = ">=3.10"
dependencies = [
    "requests>=2.31",
    "rich",
]

[project.scripts]
mycli = "flask_sqlalchemy:main"

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

Đổi tên gói sang My_Cool.Lib để thấy PyPI gộp -_. và viết thường, còn tên import dùng dấu gạch dưới.

Dùng uv xuyên suốt

Cả bài dùng uv để dựng dự án, build và phát hành. Đọc & cài uv ở tài liệu chính thức: docs.astral.sh/uv - trang cài đặt nhanh: getting-started/installation.

Một thư viện chuyên nghiệp không phải “một đống file ở gốc”. Bố cục được Python Packaging Guide khuyến nghị là src/ layout:

cây thư mục một thư viện chuẩn

mylib/                      # thu muc du an (repo)
├── pyproject.toml          # "danh thiep" cua goi (metadata + build)
├── README.md
├── LICENSE
├── src/
│   └── mylib/              # PACKAGE (ten import)
│       ├── __init__.py     # public API: __all__, __version__
│       ├── core.py         # code that su
│       └── py.typed        # danh dau "goi nay co type hints"
└── tests/
    └── test_core.py        # pytest

__init__.pymặt tiền của gói: nó quyết định ai gọi import mylib thì thấy gì. Khai báo API công khai ở đây, giấu phần còn lại:

src/mylib/__init__.py - chỉ lộ thứ bạn muốn

from mylib.core import cong, Diem   # nang len cap goi

__all__ = ["cong", "Diem"]          # "API cong khai" chinh thuc
__version__ = "0.1.0"               # mot nguon su that ve phien ban

Vì sao src/ layout?

Để package nằm trong src/, thư mục gốc KHÔNG nằm trên sys.path khi chạy test → import mylib lấy gói bạn đã cài (giống người dùng thật), không phải thư mục nguồn cạnh đó. Nhờ vậy bạn phát hiện sớm lỗi “quên đưa file vào gói”.
  • src/<package>/ giữ code; tests/ tách riêng; pyproject.toml ở gốc.
  • __init__.py định nghĩa public API (__all__) - phần còn lại coi như nội bộ.
  • Một __version__ duy nhất; tên file/module dùng chữ thường, gạch dưới.

pyproject.toml là tệp cấu hình CHUẨN (PEP 621) mô tả gói: tên, phiên bản, phụ thuộc, và build-system dùng để đóng gói. Đây là bản tối thiểu “đủ chuyên nghiệp”:

pyproject.toml

[project]
name = "mylib"                     # ten tren PyPI (se duoc chuan hoa)
version = "0.1.0"
description = "Thu vien vi du"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [{ name = "Ban", email = "ban@example.com" }]
dependencies = [
    "requests>=2.31",              # phu thuoc luc CHAY
]

[project.optional-dependencies]
dev = ["pytest", "mypy"]           # cai khi phat trien: uv add --dev ...

[project.scripts]
mycli = "mylib:main"              # tao lenh CLI -> goi ham main()

[build-system]
requires = ["hatchling"]           # cong cu build (backend)
build-backend = "hatchling.build"
  • [project]: metadata + dependencies (phụ thuộc lúc chạy) + requires-python.
  • [project.scripts]: biến gói thành lệnh CLI - “ten = package.module:ham”.
  • [build-system]: backend đóng gói (hatchling phổ biến, gọn); uv đọc tệp này để build.

Một thư viện tốt không chỉ chạy được - nó dễ dùng đúng. Ba thói quen tách bạn khỏi “script tạm bợ”, và cả ba bạn đã học ở các bài trước:

src/mylib/core.py - có kiểu, có docstring

def cong(a: int, b: int) -> int:
    """Cong hai so nguyen. Vi du:

    >>> cong(2, 3)
    5
    """
    return a + b

tests/test_core.py - pytest (như bài Unit test)

from mylib import cong

def test_cong():
    assert cong(2, 3) == 5
    assert cong(-1, 1) == 0

Kết quả khi chạy

$ uv run pytest -q
1 passed

Đừng quên py.typed (PEP 561)

Mặc định mypy bỏ qua kiểu của thư viện bên thứ ba. Thêm một file RỖNG src/mylib/py.typed và khai báo nó là package-data → người dùng thư viện của bạn được kiểm kiểu xuyên qua API. Với hatchling, đảm bảo file này được gói kèm (mặc định hatchling lấy cả thư mục package).
  • Type hints công khai + py.typed: API của bạn được mypy kiểm ở phía người dùng.
  • Test đi kèm (pytest) - như bài Unit/Integration test; chạy uv run pytest.
  • Semantic Versioning: MAJOR.MINOR.PATCH; đã phát hành thì KHÔNG sửa, chỉ tăng số.

Liên hệ

Hai bài trước cho bạn nguyên liệu của một thư viện tử tế: Type hints & mypyUnit test với pytest. Đóng gói là bước biến chúng thành thứ người khác cài được.

Khi gói đã sẵn sàng, uv build tạo ra hai tạo phẩm trong dist/: một wheel (đã build, cài nhanh) và một sdist (nguồn). Phát hành cả hai lên PyPI:

quy trình phát hành (thử TestPyPI trước!)

# 1) Build -> sinh dist/mylib-0.1.0-py3-none-any.whl + mylib-0.1.0.tar.gz
uv build

# 2) Thu nghiem an toan: day len TestPyPI truoc
uv publish --publish-url https://test.pypi.org/legacy/ --token pypi-...

# 3) On roi thi phat hanh that len PyPI
uv publish --token pypi-...

# Nguoi dung cai:
uv add mylib

Đọc & cài uv - tài liệu chính thức

Hướng dẫn build & phát hành đầy đủ (token, index, đăng nhập) ở tài liệu uv: docs.astral.sh/uv/guides/package. Cài uv lần đầu: getting-started/installation.
  • uv build → wheel (.whl, cài nhanh) + sdist (.tar.gz, nguồn); phát hành cả hai.
  • LUÔN thử TestPyPI trước, rồi mới PyPI; xác thực bằng API token (đừng dùng mật khẩu).
  • Mỗi phiên bản chỉ phát hành MỘT lần - sai thì tăng số, build lại, phát hành bản mới.

Mặt kia của đóng gói là dùng thư viện - của bạn và của người khác - đúng phiên bản. uv add ghi ràng buộc vào pyproject.toml và khoá phiên bản chính xác vào uv.lock để tái lập:

thêm & khoá phụ thuộc

uv add "requests>=2.31,<3"     # rang buoc: >=2.31 va <3
uv add --dev pytest mypy        # phu thuoc phat trien
uv add --editable ../mylib      # dung goi LOCAL dang phat trien (-e)

uv lock                         # khoa phien ban chinh xac vao uv.lock
uv sync                         # dung lai dung moi truong tu lockfile

Ràng buộc phiên bản là cách bạn nói “nhận bản mới sửa lỗi, nhưng đừng nhảy bản phá vỡ tương thích”. Mở lại tab “Ràng buộc phiên bản” ở widget Bước 1 và thử ~=1.4.2:

  • uv add ghi ràng buộc vào pyproject; uv.lock khoá phiên bản chính xác → tái lập.
  • ~=1.4.2 = >=1.4.2 và <1.5 (nhận bản vá, chặn bản phá vỡ); == ghim cứng một bản.
  • uv add --editable ../mylib (-e): dùng thư viện local, sửa nguồn là thấy ngay - hợp khi vừa viết vừa dùng.

Tiếp theo

Giờ bạn dựng được thư viện đúng chuẩn: bố cục src/, pyproject, kiểu & test, build & phát hành, và dùng đúng phiên bản. Ở dự án cuối khoá, hãy đóng gói một phần dự án của bạn thành thư viện tái dùng.

Câu hỏi thường gặp

module = MỘT file .py (vd calc.pyimport calc). package = một THƯ MỤC có __init__.py, gom nhiều module (vd mylib/import mylib.core). distribution (gói phát hành) = đơn vị bạn CÀI/CHIA SẺ - một file wheel/sdist đăng trên PyPI mà uv add/pip install kéo về. Một distribution có thể chứa nhiều package. Đừng lẫn: “gói” trong tiếng Việt nói chung là distribution.

Tên distribution (trên PyPI) và tên import là HAI thứ khác nhau. PyPI chuẩn hoá tên theo PEP 503 (viết thường, gộp - _ . thành một dấu gạch) nên Flask-SQLAlchemy, flask_sqlalchemy, flask.sqlalchemy là CÙNG một gói. Nhưng khi viết code bạn import flask_sqlalchemy (dấu gạch dưới). Tác giả CÓ THỂ đặt tên import khác hẳn tên dist - nên luôn đọc README. uv add <tên-dist>; import <tên-package>.

Quy ước đặt tên & cấu trúc - Python Packaging Guide →

Với src/ layout (package nằm trong src/), thư mục gốc KHÔNG nằm trên sys.path khi chạy test → import sẽ lấy gói bạn ĐÃ CÀI (như người dùng thật), không phải thư mục nguồn cạnh đó. Nhờ vậy lỗi “quên đưa file vào gói” bị phát hiện sớm, và test phản ánh đúng trải nghiệm cài đặt. Đây là bố cục được Packaging Guide khuyến nghị.

sdist (.tar.gz) là gói NGUỒN - cần build lại khi cài. wheel (.whl) là gói ĐÃ build sẵn - cài nhanh, không cần toolchain. Phát hành CẢ HAI (uv build sinh cả hai): wheel cho tốc độ, sdist để ai cần build từ nguồn (hoặc nền tảng lạ) vẫn được. PyPI nhận cả hai.

Mặc định mypy BỎ QUA type hints của thư viện bên thứ ba (sợ hint sai). Thêm một file RỖNG tên py.typed vào trong package và khai báo nó là package-data (PEP 561) → báo cho công cụ “gói này có kiểu, cứ dùng”. Nhờ vậy người dùng thư viện của bạn được mypy kiểm kiểu xuyên qua API của bạn. Một dòng cấu hình đổi lấy trải nghiệm chuyên nghiệp.

Theo Semantic Versioning: MAJOR.MINOR.PATCH. Tăng PATCH khi sửa lỗi tương thích ngược; tăng MINOR khi thêm tính năng vẫn tương thích; tăng MAJOR khi phá vỡ tương thích (đổi/loại bỏ API). Người dùng dựa vào đó để viết ràng buộc an toàn như ~=1.4 (nhận mọi 1.x từ 1.4). Quy tắc sắt: ĐÃ phát hành một phiên bản thì KHÔNG sửa lại - muốn đổi thì tăng số rồi phát hành bản mới.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Phân biệt module, package và distribution như thế nào?

  1. 1

    Dựng gói theo src/ layout

    Tạo dự án: uv init --lib mylib (hoặc tự tạo src/mylib/__init__.py + pyproject.toml). Viết một hàm có type hint trong src/mylib/core.py và export nó ở __init__.py (__all__).

    Hoàn thành khi: Cây thư mục có src/mylib/__init__.py, pyproject.toml, tests/; uv run python -c "import mylib; print(mylib.__version__)" chạy được.

  2. 2

    pyproject.toml đầy đủ

    Bổ sung pyproject: description, requires-python, một dependency, và [project.scripts] tạo một lệnh CLI trỏ tới hàm main. Dùng widget trong bài để đối chiếu phần [project].

    Hoàn thành khi: uv run mycli chạy hàm main; tên gói được chuẩn hoá đúng (viết thường, dấu gạch).

  3. 3

    Kiểu công khai + py.typed

    Thêm file rỗng src/mylib/py.typed và khai báo package-data. Ở một dự án khác, uv add --editable ../mylib rồi chạy mypy - kiểm tra mypy THẤY kiểu của bạn.

    Hoàn thành khi: mypy báo lỗi khi bạn gọi sai kiểu hàm trong mylib (chứ không “bỏ qua vì thiếu type”).

  4. 4

    Build & thử trên TestPyPI

    Chạy uv build (xem dist/*.whl*.tar.gz). Tạo tài khoản TestPyPI, tạo token, rồi uv publish --publish-url https://test.pypi.org/legacy/ . Cài lại từ TestPyPI ở môi trường mới.

    Hoàn thành khi: Có hai tệp trong dist/; gói xuất hiện trên test.pypi.org; uv add --index ... cài lại được.

  5. 5

    Ràng buộc phiên bản

    Với widget (tab “Ràng buộc phiên bản”), tìm một ràng buộc CHỈ chấp nhận 1.2.0 và 1.4.2 trong danh sách. Giải thích vì sao ~=1.4.2 loại 1.5.0.

    Hoàn thành khi: Viết được ràng buộc đúng; giải thích ~=1.4.2 nghĩa là >=1.4.2<1.5.