Virtual environment, pip và pyproject.toml trong Python
1. Tổng quan
Khi làm nhiều dự án Python trên cùng một máy, bạn sẽ sớm gặp xung đột phiên bản. Dự án A cần requests 2.28, dự án B lại cần requests 2.32. Bài này hướng dẫn ba công cụ giải quyết vấn đề đó. venv cô lập môi trường cho từng dự án. pip cài và quản lý package. pyproject.toml khai báo dự án theo chuẩn hiện đại. Đọc xong, bạn sẽ dựng được một dự án Python có cấu trúc chuẩn, cài đặt được ở chế độ editable và có test chạy bằng pytest.
2. Virtual environment
Một virtual environment (venv) là một thư mục chứa ba thứ: một bản Python interpreter riêng (thường là symlink), một thư mục site-packages riêng và các script kích hoạt. Package bạn cài trong venv chỉ nằm trong thư mục đó, không ảnh hưởng tới Python của hệ thống.
2.1. Tạo và kích hoạt venv
Module venv có sẵn trong thư viện chuẩn từ Python 3.3, nên bạn không cần cài thêm gì:
mkdir shopcart && cd shopcart
python3 -m venv .venv
# Linux / macOS
source .venv/bin/activate
# Windows (PowerShell)
.venv\Scripts\Activate.ps1
Lệnh python3 -m venv .venv tạo thư mục .venv trong dự án. Đặt tên .venv là quy ước phổ biến, và phần lớn IDE sẽ tự nhận ra nó. Sau khi kích hoạt, prompt của shell sẽ có tiền tố (.venv). Lệnh python và pip lúc này trỏ vào venv.
Muốn thoát khỏi venv, bạn chạy:
deactivate
2.2. Kiểm tra bạn đang dùng interpreter nào
Đây là bước nhiều người bỏ qua, và nó là nguồn gốc của rất nhiều lỗi kiểu “đã cài rồi mà vẫn ModuleNotFoundError”:
which python
python -c "import sys; print(sys.prefix); print(sys.prefix != sys.base_prefix)"
/home/lan/shopcart/.venv/bin/python
/home/lan/shopcart/.venv
True
sys.prefix là thư mục gốc của môi trường đang chạy. sys.base_prefix là thư mục của bản Python gốc. Hai giá trị khác nhau nghĩa là bạn đang ở trong venv.
Lưu ý: Đừng commit thư mục
.venvlên Git. Hãy thêm.venv/vào.gitignore. Venv phụ thuộc vào đường dẫn tuyệt đối và hệ điều hành, nên copy sang máy khác thường sẽ hỏng. Cách đúng là commit file khai báo dependency rồi tạo lại venv trên máy mới.
3. Quản lý package với pip
pip là trình cài package mặc định, lấy package từ PyPI.
3.1. Các lệnh cơ bản
python -m pip install --upgrade pip
python -m pip install "requests>=2.31,<3"
python -m pip list
python -m pip show requests
python -m pip uninstall -y requests
Từng lệnh làm việc sau:
install --upgrade pipnâng cấp chính pip. Bản pip cũ đôi khi không đọc đượcpyproject.tomlđúng cách."requests>=2.31,<3"là một version specifier. Bạn cần đặt nó trong dấu nháy để shell không hiểu<thành chuyển hướng.listliệt kê package đã cài.showin thông tin chi tiết, gồm phiên bản, vị trí cài và các package mà nó phụ thuộc.
3.2. Vì sao nên dùng python -m pip thay vì pip
Lệnh pip trần có thể trỏ tới một Python khác với python bạn đang dùng. Chuyện này hay xảy ra khi máy có nhiều phiên bản Python, hoặc khi bạn quên kích hoạt venv. Lệnh python -m pip luôn chạy pip của đúng interpreter đó, nên package chắc chắn được cài vào môi trường bạn mong muốn.
3.3. requirements.txt và pip freeze
Cách truyền thống để chia sẻ danh sách package là dùng requirements.txt:
python -m pip freeze > requirements.txt
python -m pip install -r requirements.txt
certifi==2024.8.30
charset-normalizer==3.4.0
idna==3.10
requests==2.32.3
urllib3==2.2.3
pip freeze ghi lại mọi package trong venv với phiên bản chính xác, kể cả dependency gián tiếp như urllib3. Cách này phù hợp để tái lập một môi trường chạy. Tuy vậy, nó không cho biết package nào bạn thực sự cần và package nào chỉ được kéo theo. Nó cũng không mô tả dự án của bạn: tên, phiên bản, yêu cầu Python. Đó là việc của pyproject.toml.
4. Khai báo dự án với pyproject.toml
pyproject.toml là file cấu hình chuẩn cho dự án Python. Nó được định nghĩa trong các chuẩn PEP 518, PEP 621 và các PEP liên quan. File này thay thế setup.py, setup.cfg và tập hợp cấu hình cho nhiều công cụ như pytest hay ruff vào cùng một chỗ.
4.1. Cấu trúc dự án
Chúng ta dùng src layout, tức là đặt code trong src/. Bố cục này giúp test luôn chạy trên package đã cài, chứ không vô tình import trực tiếp từ thư mục làm việc:
shopcart/
├── pyproject.toml
├── src/
│ └── shopcart/
│ ├── __init__.py
│ ├── order.py
│ └── cli.py
└── tests/
└── test_order.py
4.2. File pyproject.toml đầy đủ
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "shopcart"
version = "0.1.0"
description = "Tính tiền đơn hàng đơn giản"
requires-python = ">=3.11"
dependencies = [
"requests>=2.31,<3",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"ruff>=0.5",
]
[project.scripts]
shopcart = "shopcart.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
Ý nghĩa từng phần:
[build-system]cho pip biết cần công cụ nào để build dự án. Ở đây là setuptools. Bạn có thể thay bằnghatchlinghoặcflit_coremà không phải sửa phần còn lại.[project]chứa metadata chuẩn.requires-pythonngăn người khác cài dự án trên phiên bản Python không hỗ trợ.dependencieslà các package bắt buộc để chạy dự án. Ở đây bạn nên khai báo khoảng phiên bản (>=2.31,<3) chứ không ghim cứng (==2.32.3).[project.optional-dependencies]nhóm các package tuỳ chọn. Nhómdevchứa công cụ phát triển mà người dùng cuối không cần.[project.scripts]tạo lệnhshopcarttrên terminal. Lệnh này gọi hàmmaintrong moduleshopcart.cli.[tool.pytest.ini_options]là cấu hình riêng của pytest. Mọi công cụ đều có namespace[tool.<tên>]của mình.
Với src layout, setuptools tự tìm package trong src/, nên bạn không cần khai báo thêm.
4.3. Code của package
# src/shopcart/order.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 phải lớn hơn 0")
self.lines.append(OrderLine(product, Decimal(unit_price), quantity))
def total(self) -> Decimal:
return sum((l.unit_price * l.quantity for l in self.lines), Decimal("0"))
Order giữ danh sách OrderLine. Giá tiền dùng Decimal để tránh sai số dấu phẩy động, nên unit_price được truyền vào dưới dạng chuỗi. Phương thức total() cộng thành tiền của từng dòng, với giá trị khởi đầu là Decimal("0").
# src/shopcart/cli.py
from shopcart.order import Order
def main() -> None:
order = Order(customer="Nguyễn Văn An")
order.add("Cà phê sữa", "29000", 2)
order.add("Bánh mì", "25000")
print(f"Khách hàng: {order.customer}")
print(f"Tổng tiền: {order.total():,} VND")
File src/shopcart/__init__.py có thể để trống.
4.4. Cài đặt ở chế độ editable
python -m pip install -e ".[dev]"
shopcart
Khách hàng: Nguyễn Văn An
Tổng tiền: 83,000 VND
Cờ -e (editable) liên kết package tới mã nguồn thay vì copy vào site-packages. Nhờ vậy, khi bạn sửa order.py, thay đổi có hiệu lực ngay mà không cần cài lại. Phần ".[dev]" nghĩa là cài dự án ở thư mục hiện tại kèm nhóm dev, nên pytest và ruff cũng được cài. Lệnh shopcart có sẵn trong terminal nhờ khai báo [project.scripts].
Lưu ý: Một số shell như zsh hiểu
[...]là glob pattern. Vì vậy bạn luôn phải đặt".[dev]"trong dấu nháy.
5. Viết và chạy test
# tests/test_order.py
from decimal import Decimal
import pytest
from shopcart.order import Order
def test_total_sums_all_lines():
order = Order(customer="Trần Thị Bình")
order.add("Trà đào", "35000", 2)
order.add("Bánh flan", "15000", 3)
assert order.total() == Decimal("115000")
def test_empty_order_has_zero_total():
assert Order(customer="Lê Minh").total() == Decimal("0")
def test_add_rejects_non_positive_quantity():
order = Order(customer="Phạm Hà")
with pytest.raises(ValueError, match="quantity"):
order.add("Nước suối", "10000", 0)
python -m pytest -q
... [100%]
3 passed in 0.02s
Test import shopcart.order như một package đã cài. Đây là lý do bạn cần bước pip install -e ở trên. Nếu chưa cài, src layout sẽ báo ModuleNotFoundError, và lỗi này là có chủ đích: nó buộc bạn kiểm thử đúng thứ người dùng sẽ cài. Test thứ ba dùng pytest.raises để kiểm tra rằng số lượng không hợp lệ bị từ chối với thông báo có chứa chữ “quantity”.
6. Khoá phiên bản: pyproject.toml hay requirements.txt?
Hai file này không loại trừ nhau. Chúng phục vụ hai mục đích khác nhau:
pyproject.toml |
File lock (requirements.txt đã ghim) |
|
|---|---|---|
| Mục đích | Mô tả dự án cần gì | Tái lập chính xác một môi trường |
| Phiên bản | Khoảng (>=2.31,<3) |
Cố định (==2.32.3) |
| Dùng cho | Thư viện và ứng dụng | Ứng dụng triển khai (server, Docker) |
Với một thư viện sẽ publish lên PyPI, bạn chỉ cần pyproject.toml. Nếu ghim cứng phiên bản, người dùng thư viện của bạn sẽ gặp xung đột.
Với một ứng dụng chạy trên server, bạn nên có thêm file lock để mọi lần deploy đều cài đúng cùng một tập phiên bản:
python -m pip install -e .
python -m pip freeze --exclude-editable > requirements.lock
Khi deploy, bạn cài từ file lock trước rồi mới cài dự án, để pip không tự nâng phiên bản dependency:
python -m pip install -r requirements.lock
python -m pip install --no-deps .
Nếu muốn quy trình lock gọn hơn, bạn có thể dùng pip-tools (lệnh pip-compile pyproject.toml) hoặc uv. Cả hai đều đọc trực tiếp pyproject.toml và sinh ra file lock có kèm hash.
7. Lỗi hay gặp
- Cài package bằng
sudo pip install. Lệnh này ghi đè package của hệ điều hành và có thể làm hỏng công cụ hệ thống. Nhiều bản phân phối Linux mới còn chặn hẳn với lỗiexternally-managed-environment. Cách khắc phục là luôn dùng venv. - Quên kích hoạt venv trước khi cài hoặc chạy. Bạn có thể kiểm tra bằng
which pythonnhư ở mục 2.2, hoặc gọi trực tiếp.venv/bin/python -m pip .... - Tạo venv bằng một Python, chạy bằng Python khác. Venv gắn chặt với interpreter đã tạo ra nó. Khi bạn nâng cấp Python (ví dụ từ 3.11 lên 3.12), hãy xoá
.venvrồi tạo lại. - Di chuyển hoặc đổi tên thư mục dự án. Các script trong
.venv/binchứa đường dẫn tuyệt đối, nên sẽ hỏng sau khi di chuyển. Bạn cần tạo lại venv. - Trộn
setup.pycũ vớipyproject.tomlvà khai báo dependency ở cả hai nơi. Hãy chọn một nguồn sự thật duy nhất. Với dự án mới, đó là bảng[project]. - Đưa công cụ dev vào
dependencies.pytesthayruffnên nằm trongoptional-dependencies(hoặc[dependency-groups]nếu công cụ của bạn hỗ trợ), để người dùng cuối không phải cài chúng.
Lưu ý: Không nên dùng venv khi bạn chỉ cần chạy một công cụ CLI độc lập như
blackhayhttpietrên toàn máy. Trường hợp này,pipxphù hợp hơn: nó tự tạo một venv riêng cho mỗi công cụ và đưa lệnh vàoPATH.
8. Kết luận
Bài viết đã đi qua bộ ba công cụ nền tảng của một dự án Python:
python -m venv .venvcô lập dependency cho từng dự án.python -m pipcài package vào đúng interpreter đang dùng.pyproject.tomlkhai báo metadata, dependency, nhóm tuỳ chọn, lệnh CLI và cấu hình công cụ trong một file duy nhất.
Kết hợp src layout, pip install -e ".[dev]" và pytest, bạn có một dự án sạch, dễ test và dễ chia sẻ. Với ứng dụng cần deploy ổn định, hãy bổ sung một file lock bên cạnh pyproject.toml để mọi môi trường đều cài đúng cùng một tập phiên bản.