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 .venv lê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 pip nâng cấp chính pip. Bản pip cũ đôi khi không đọc được pyproject.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.
  • list liệt kê package đã cài. show in 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ằng hatchling hoặc flit_core mà không phải sửa phần còn lại.
  • [project] chứa metadata chuẩn. requires-python ngăn người khác cài dự án trên phiên bản Python không hỗ trợ.
  • dependencies là 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óm dev chứa công cụ phát triển mà người dùng cuối không cần.
  • [project.scripts] tạo lệnh shopcart trên terminal. Lệnh này gọi hàm main trong module shopcart.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ỗi externally-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 python như ở 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á .venv rồi tạo lại.
  • Di chuyển hoặc đổi tên thư mục dự án. Các script trong .venv/bin chứ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.py cũ với pyproject.toml và 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. pytest hay ruff nên nằm trong optional-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ư black hay httpie trên toàn máy. Trường hợp này, pipx phù hợp hơn: nó tự tạo một venv riêng cho mỗi công cụ và đưa lệnh vào PATH.

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 .venv cô lập dependency cho từng dự án.
  • python -m pip cài package vào đúng interpreter đang dùng.
  • pyproject.toml khai 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.

pythonpipvenvpyprojectpackaging