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ùng và chia 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):
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
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__.py là mặ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?
- ▸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)
- ▸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ệ
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
- ▸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
Câu hỏi thường gặp
module = MỘT file .py (vd calc.py → import 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>.
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.
Trả lời vài câu để chắc rằng em đã nắm bài.
Phân biệt module, package và distribution như thế nào?
- 1
Dựng gói theo src/ layout
Tạo dự án:
uv init --lib mylib(hoặc tự tạosrc/mylib/__init__.py+pyproject.toml). Viết một hàm có type hint trongsrc/mylib/core.pyvà 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
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àmmain. Dùng widget trong bài để đối chiếu phần[project].Hoàn thành khi:
uv run myclichạy hàmmain; tên gói được chuẩn hoá đúng (viết thường, dấu gạch). - 3
Kiểu công khai + py.typed
Thêm file rỗng
src/mylib/py.typedvà khai báo package-data. Ở một dự án khác,uv add --editable ../mylibrồi chạymypy- kiểm tra mypy THẤY kiểu của bạn.Hoàn thành khi:
mypybáo lỗi khi bạn gọi sai kiểu hàm trongmylib(chứ không “bỏ qua vì thiếu type”). - 4
Build & thử trên TestPyPI
Chạy
uv build(xemdist/*.whlvà*.tar.gz). Tạo tài khoản TestPyPI, tạo token, rồiuv 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
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.2loại 1.5.0.Hoàn thành khi: Viết được ràng buộc đúng; giải thích
~=1.4.2nghĩa là>=1.4.2và<1.5.