Skip to content

pyproject.toml & Dependencies Trung cấp

pyproject.toml = Tương lai của Python packaging = Một file để rule them all

Learning Outcomes

Sau khi hoàn thành trang này, bạn sẽ:

  • ✅ Hiểu cấu trúc pyproject.toml và các sections quan trọng
  • ✅ Migrate từ setup.py/setup.cfg sang pyproject.toml
  • ✅ Quản lý dependencies với version constraints hiệu quả
  • ✅ Hiểu lock files và tại sao chúng quan trọng
  • ✅ So sánh build backends: setuptools, hatch, flit, maturin

Tại sao pyproject.toml?

The Old Way (setup.py Hell)

python
# setup.py - Dynamic, khó parse, security risks
from setuptools import setup, find_packages

setup(
    name="my-package",
    version="0.1.0",
    packages=find_packages(),
    install_requires=[
        "requests>=2.25.0",
        "numpy>=1.19.0",
    ],
    extras_require={
        "dev": ["pytest", "black"],
    },
    # ... 50+ more options
)

Vấn đề với setup.py:

  • ❌ Executable Python code → Security risks
  • ❌ Khó parse bằng tools (phải execute để biết metadata)
  • ❌ Không standardized format
  • ❌ Chicken-and-egg: cần setuptools để đọc setup.py, nhưng setup.py define setuptools version

The New Way (pyproject.toml)

toml
# pyproject.toml - Static, declarative, standardized
[project]
name = "my-package"
version = "0.1.0"
dependencies = [
    "requests>=2.25.0",
    "numpy>=1.19.0",
]

[project.optional-dependencies]
dev = ["pytest", "black"]

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

Ưu điểm:

  • ✅ Static, declarative → Easy to parse
  • ✅ Standardized (PEP 517, 518, 621)
  • ✅ Tool-agnostic (works với setuptools, poetry, hatch, flit)
  • ✅ Single source of truth cho project metadata

Cấu trúc pyproject.toml

Anatomy of pyproject.toml

toml
# ============================================
# BUILD SYSTEM (PEP 517/518)
# Defines how to build the package
# ============================================
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

# ============================================
# PROJECT METADATA (PEP 621)
# Core package information
# ============================================
[project]
name = "my-awesome-package"
version = "1.0.0"
description = "A short description of my package"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
    {name = "Your Name", email = "you@example.com"},
]
maintainers = [
    {name = "Maintainer", email = "maintainer@example.com"},
]
keywords = ["python", "packaging", "example"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]

# Dependencies
dependencies = [
    "requests>=2.28.0",
    "pydantic>=2.0.0",
]

# Optional dependencies (extras)
[project.optional-dependencies]
dev = [
    "pytest>=7.0.0",
    "pytest-cov>=4.0.0",
    "black>=23.0.0",
    "mypy>=1.0.0",
    "ruff>=0.1.0",
]
docs = [
    "mkdocs>=1.5.0",
    "mkdocs-material>=9.0.0",
]

# Entry points
[project.scripts]
my-cli = "my_package.cli:main"

[project.gui-scripts]
my-gui = "my_package.gui:main"

[project.entry-points."my_package.plugins"]
plugin1 = "my_package.plugins:Plugin1"

# URLs
[project.urls]
Homepage = "https://github.com/username/my-package"
Documentation = "https://my-package.readthedocs.io"
Repository = "https://github.com/username/my-package"
Changelog = "https://github.com/username/my-package/blob/main/CHANGELOG.md"

# ============================================
# TOOL CONFIGURATIONS
# Settings for various development tools
# ============================================

[tool.setuptools]
packages = ["my_package"]
package-dir = {"" = "src"}

[tool.setuptools.package-data]
my_package = ["py.typed", "data/*.json"]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-ra -q --strict-markers"

[tool.black]
line-length = 88
target-version = ["py310", "py311", "py312"]

[tool.ruff]
line-length = 88
select = ["E", "F", "I", "N", "W", "UP"]

[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true

Version Constraints

Syntax Overview

toml
[project]
dependencies = [
    # Exact version (không khuyến khích cho libraries)
    "package==1.2.3",
    
    # Minimum version
    "package>=1.2.3",
    
    # Maximum version
    "package<2.0.0",
    
    # Range
    "package>=1.2.3,<2.0.0",
    
    # Compatible release (>=1.2.3, <2.0.0)
    "package~=1.2.3",
    
    # Compatible release (>=1.2, <2.0)
    "package~=1.2",
    
    # Wildcard (any 1.2.x)
    "package==1.2.*",
    
    # Exclude specific version
    "package>=1.0.0,!=1.2.3",
    
    # Multiple constraints
    "package>=1.2.3,<2.0.0,!=1.5.0",
]

Semantic Versioning (SemVer)

MAJOR.MINOR.PATCH
  │     │     │
  │     │     └── Bug fixes (backward compatible)
  │     └──────── New features (backward compatible)
  └────────────── Breaking changes (NOT backward compatible)

Examples:
1.0.0 → 1.0.1  # Bug fix
1.0.0 → 1.1.0  # New feature
1.0.0 → 2.0.0  # Breaking change

Best Practices cho Version Constraints

toml
# ❌ BAD: Quá strict (khó update)
dependencies = [
    "requests==2.31.0",
    "numpy==1.26.0",
]

# ❌ BAD: Quá loose (có thể break)
dependencies = [
    "requests",
    "numpy",
]

# ✅ GOOD: Flexible nhưng safe
dependencies = [
    "requests>=2.28.0,<3.0.0",
    "numpy>=1.24.0,<2.0.0",
]

# ✅ GOOD: Dùng compatible release operator
dependencies = [
    "requests~=2.28",  # >=2.28.0, <3.0.0
    "numpy~=1.24",     # >=1.24.0, <2.0.0
]

Environment Markers

toml
[project]
dependencies = [
    # Platform-specific
    "pywin32>=300; sys_platform == 'win32'",
    "pyobjc>=9.0; sys_platform == 'darwin'",
    
    # Python version specific
    "typing-extensions>=4.0; python_version < '3.11'",
    "tomli>=2.0; python_version < '3.11'",
    
    # Implementation specific
    "cffi>=1.15; implementation_name == 'cpython'",
]

Lock Files

Tại sao cần Lock Files?

toml
# pyproject.toml
dependencies = ["requests>=2.28.0"]

# Ngày 1: pip install → requests==2.28.0
# Ngày 30: pip install → requests==2.31.0 (new release)
# → Different versions = Different behavior = Bugs!

Lock File = Snapshot của Dependencies

txt
# requirements.txt (lock file từ pip-tools)
#
# This file is autogenerated by pip-compile with Python 3.12
#
certifi==2023.11.17
    # via requests
charset-normalizer==3.3.2
    # via requests
idna==3.6
    # via requests
requests==2.31.0
    # via -r requirements.in
urllib3==2.1.0
    # via requests
toml
# poetry.lock (excerpt)
[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
python-versions = ">=3.7"

[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"

Lock File Workflow

bash
# === PIP-TOOLS ===
# 1. Define direct dependencies
echo "requests>=2.28.0" > requirements.in

# 2. Generate lock file
pip-compile requirements.in -o requirements.txt

# 3. Install from lock file
pip-sync requirements.txt

# 4. Update dependencies
pip-compile --upgrade requirements.in

# === POETRY ===
# 1. Add dependency (auto-updates poetry.lock)
poetry add requests

# 2. Install from lock file
poetry install

# 3. Update dependencies
poetry update

# === UV ===
# 1. Add dependency
uv add requests

# 2. Lock dependencies
uv lock

# 3. Install from lock file
uv sync

# 4. Update dependencies
uv lock --upgrade

Commit Lock Files!

ini
# ❌ BAD: Ignore lock files
poetry.lock
requirements.txt

# ✅ GOOD: Commit lock files
# (Không có entry cho lock files)

Build Backends

So sánh Build Backends

BackendUse CaseSpeedFeatures
setuptoolsGeneral, legacyMediumFull-featured
hatchModern projectsFastEnvironments, versioning
flitPure PythonFastSimple, minimal
maturinRust extensionsFastRust + Python
poetry-corePoetry projectsMediumPoetry ecosystem
toml
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
packages = ["my_package"]
package-dir = {"" = "src"}

[tool.setuptools.package-data]
my_package = ["py.typed", "data/*.json"]

[tool.setuptools.dynamic]
version = {attr = "my_package.__version__"}

Khi nào dùng:

  • Legacy projects
  • Cần C extensions
  • Complex build requirements

2. hatch (Modern, Feature-rich)

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

[tool.hatch.version]
path = "src/my_package/__about__.py"

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

[tool.hatch.envs.default]
dependencies = [
    "pytest",
    "pytest-cov",
]

[tool.hatch.envs.default.scripts]
test = "pytest {args:tests}"
cov = "pytest --cov=my_package {args:tests}"
bash
# Hatch commands
hatch new my-project      # Create new project
hatch env create          # Create environment
hatch run test            # Run tests
hatch build               # Build package
hatch publish             # Publish to PyPI
hatch version minor       # Bump version

Khi nào dùng:

  • New projects
  • Cần environment management
  • Cần version management

3. flit (Simple, Pure Python)

toml
[build-system]
requires = ["flit_core>=3.4"]
build-backend = "flit_core.buildapi"

[project]
name = "my-package"
dynamic = ["version", "description"]

[tool.flit.module]
name = "my_package"
bash
# Flit commands
flit init                 # Initialize project
flit build                # Build package
flit publish              # Publish to PyPI
flit install --symlink    # Install in development mode

Khi nào dùng:

  • Pure Python packages
  • Simple projects
  • Minimal configuration

4. maturin (Rust Extensions)

toml
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"

[project]
name = "my-rust-package"
requires-python = ">=3.8"

[tool.maturin]
features = ["pyo3/extension-module"]
python-source = "python"
module-name = "my_rust_package._core"
bash
# Maturin commands
maturin init              # Initialize project
maturin develop           # Build and install for development
maturin build             # Build wheels
maturin publish           # Publish to PyPI

Khi nào dùng:

  • Rust + Python projects
  • Performance-critical code
  • PyO3 bindings

Migration Guide: setup.py → pyproject.toml

Step 1: Analyze Current setup.py

python
# setup.py (before)
from setuptools import setup, find_packages

setup(
    name="my-package",
    version="1.0.0",
    description="My awesome package",
    long_description=open("README.md").read(),
    long_description_content_type="text/markdown",
    author="Your Name",
    author_email="you@example.com",
    url="https://github.com/username/my-package",
    packages=find_packages(where="src"),
    package_dir={"": "src"},
    python_requires=">=3.10",
    install_requires=[
        "requests>=2.28.0",
        "pydantic>=2.0.0",
    ],
    extras_require={
        "dev": ["pytest", "black"],
    },
    entry_points={
        "console_scripts": [
            "my-cli=my_package.cli:main",
        ],
    },
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
    ],
)

Step 2: Create pyproject.toml

toml
# pyproject.toml (after)
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "my-package"
version = "1.0.0"
description = "My awesome package"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
    {name = "Your Name", email = "you@example.com"},
]
classifiers = [
    "Programming Language :: Python :: 3",
    "License :: OSI Approved :: MIT License",
]
dependencies = [
    "requests>=2.28.0",
    "pydantic>=2.0.0",
]

[project.optional-dependencies]
dev = ["pytest", "black"]

[project.scripts]
my-cli = "my_package.cli:main"

[project.urls]
Homepage = "https://github.com/username/my-package"

[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools.packages.find]
where = ["src"]

Step 3: Remove Old Files

bash
# Xóa setup.py (hoặc giữ minimal version cho editable installs)
rm setup.py

# Xóa setup.cfg nếu có
rm setup.cfg

# Xóa MANIFEST.in nếu không cần
rm MANIFEST.in

Step 4: Test Build

bash
# Build package
python -m build

# Check distribution
twine check dist/*

# Test install
pip install dist/my_package-1.0.0-py3-none-any.whl

Production Pitfalls ⚠️

1. Quên specify Python version

toml
# ❌ BAD: Không có requires-python
[project]
name = "my-package"
dependencies = ["pydantic>=2.0"]  # Pydantic 2 cần Python 3.8+

# ✅ GOOD: Specify Python version
[project]
name = "my-package"
requires-python = ">=3.10"
dependencies = ["pydantic>=2.0"]

2. Dùng dynamic version sai cách

toml
# ❌ BAD: Dynamic version từ file không tồn tại
[project]
dynamic = ["version"]

[tool.setuptools.dynamic]
version = {attr = "my_package.version.__version__"}  # File không có!

# ✅ GOOD: Đảm bảo file tồn tại
# src/my_package/__init__.py
# __version__ = "1.0.0"

[tool.setuptools.dynamic]
version = {attr = "my_package.__version__"}

3. Không include package data

toml
# ❌ BAD: Data files không được include
[tool.setuptools]
packages = ["my_package"]
# Missing package-data!

# ✅ GOOD: Include data files
[tool.setuptools.package-data]
my_package = [
    "py.typed",
    "data/*.json",
    "templates/*.html",
]

4. Conflicting tool configurations

toml
# ❌ BAD: Black và Ruff có line-length khác nhau
[tool.black]
line-length = 88

[tool.ruff]
line-length = 120  # Conflict!

# ✅ GOOD: Consistent configuration
[tool.black]
line-length = 88

[tool.ruff]
line-length = 88

5. Không test build trước khi publish

bash
# ❌ BAD: Publish trực tiếp
poetry publish

# ✅ GOOD: Test build trước
python -m build
twine check dist/*
pip install dist/*.whl
# Test package works
poetry publish  # hoặc twine upload

Quick Reference

toml
# === MINIMAL pyproject.toml ===
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "my-package"
version = "1.0.0"
dependencies = ["requests>=2.28"]

# === VERSION CONSTRAINTS ===
"package==1.2.3"      # Exact
"package>=1.2.3"      # Minimum
"package<2.0.0"       # Maximum
"package~=1.2"        # Compatible (>=1.2, <2.0)
"package>=1.2,<2.0"   # Range

# === COMMON TOOL CONFIGS ===
[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.black]
line-length = 88

[tool.ruff]
select = ["E", "F", "I"]

[tool.mypy]
strict = true