Giao diện
Distribution & Publishing Nâng cao
Distribution = Đóng gói và phân phối Python packages = Từ code đến PyPI
Learning Outcomes
Sau khi hoàn thành trang này, bạn sẽ:
- ✅ Hiểu sự khác biệt giữa source distributions và wheels
- ✅ Build và publish packages lên PyPI
- ✅ Thiết lập private registries cho internal packages
- ✅ Áp dụng Semantic Versioning (SemVer) đúng cách
- ✅ Tự động hóa release process với CI/CD
Distribution Formats
Source Distribution (sdist) vs Wheel
| Feature | Source Distribution (sdist) | Wheel (.whl) |
|---|---|---|
| Format | .tar.gz | .whl (ZIP) |
| Contains | Source code | Pre-built code |
| Build required | ✅ Yes | ❌ No |
| Install speed | Slow | Fast |
| C extensions | Needs compiler | Pre-compiled |
| Platform | Any | Platform-specific* |
*Pure Python wheels are platform-independent
Wheel Types
# Pure Python wheel (works everywhere)
my_package-1.0.0-py3-none-any.whl
# │ │ │ │
# │ │ │ └── Any architecture
# │ │ └─────── No ABI tag
# │ └─────────── Python 3
# └───────────────── Version
# Platform-specific wheel (C extensions)
my_package-1.0.0-cp312-cp312-manylinux_2_17_x86_64.whl
# │ │ │ │
# │ │ │ └── Platform (Linux x86_64)
# │ │ └──────── ABI tag
# │ └────────────── CPython 3.12
# └──────────────────── Version
# macOS wheel
my_package-1.0.0-cp312-cp312-macosx_10_9_x86_64.whl
# Windows wheel
my_package-1.0.0-cp312-cp312-win_amd64.whlBuilding Packages
Using build (Recommended)
bash
# Install build tool
pip install build
# Build both sdist and wheel
python -m build
# Build only wheel
python -m build --wheel
# Build only sdist
python -m build --sdist
# Output in dist/
ls dist/
# my_package-1.0.0.tar.gz
# my_package-1.0.0-py3-none-any.whlUsing Poetry
bash
# Build with Poetry
poetry build
# Output
# dist/my_package-1.0.0.tar.gz
# dist/my_package-1.0.0-py3-none-any.whlUsing Hatch
bash
# Build with Hatch
hatch build
# Build specific format
hatch build --target wheel
hatch build --target sdistUsing uv
bash
# Build with uv
uv build
# Output in dist/Verifying Build
bash
# Install twine for verification
pip install twine
# Check distribution files
twine check dist/*
# Expected output:
# Checking dist/my_package-1.0.0.tar.gz: PASSED
# Checking dist/my_package-1.0.0-py3-none-any.whl: PASSEDPublishing to PyPI
PyPI Account Setup
- Create account: https://pypi.org/account/register/
- Enable 2FA: Required for new projects
- Create API token: https://pypi.org/manage/account/token/
Configure Credentials
bash
# Option 1: Environment variables (recommended for CI)
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-xxxxxxxxxxxxx
# Option 2: .pypirc file (for local development)
cat > ~/.pypirc << EOF
[pypi]
username = __token__
password = pypi-xxxxxxxxxxxxx
[testpypi]
username = __token__
password = pypi-xxxxxxxxxxxxx
EOF
# Secure the file
chmod 600 ~/.pypircPublishing with Twine
bash
# Install twine
pip install twine
# Upload to TestPyPI first (recommended)
twine upload --repository testpypi dist/*
# Test install from TestPyPI
pip install --index-url https://test.pypi.org/simple/ my-package
# Upload to PyPI (production)
twine upload dist/*Publishing with Poetry
bash
# Configure PyPI token
poetry config pypi-token.pypi pypi-xxxxxxxxxxxxx
# Publish to TestPyPI
poetry publish --repository testpypi
# Publish to PyPI
poetry publish
# Build and publish in one command
poetry publish --buildPublishing with Hatch
bash
# Publish with Hatch
hatch publish
# Publish to TestPyPI
hatch publish --repo testPublishing with uv
bash
# Publish with uv
uv publish
# With token
uv publish --token pypi-xxxxxxxxxxxxxPrivate Registries
Why Private Registries?
- 🔒 Internal packages không public
- 🏢 Company-specific libraries
- 🔐 Proprietary code
- 📦 Pre-release testing
Options for Private Registries
| Solution | Hosting | Cost | Features |
|---|---|---|---|
| PyPI (private) | Cloud | Free (limited) | Basic |
| AWS CodeArtifact | AWS | Pay-per-use | AWS integration |
| GCP Artifact Registry | GCP | Pay-per-use | GCP integration |
| Azure Artifacts | Azure | Pay-per-use | Azure integration |
| JFrog Artifactory | Self/Cloud | $$ | Enterprise |
| Nexus Repository | Self-hosted | Free/$ | Full-featured |
| devpi | Self-hosted | Free | Simple |
AWS CodeArtifact Setup
bash
# Login to CodeArtifact
aws codeartifact login \
--tool pip \
--domain my-domain \
--domain-owner 123456789012 \
--repository my-repo
# Configure pip
pip config set global.index-url https://aws:TOKEN@my-domain-123456789012.d.codeartifact.us-east-1.amazonaws.com/pypi/my-repo/simple/
# Publish
twine upload \
--repository-url https://my-domain-123456789012.d.codeartifact.us-east-1.amazonaws.com/pypi/my-repo/ \
dist/*GCP Artifact Registry Setup
bash
# Configure pip
pip config set global.index-url https://us-central1-python.pkg.dev/my-project/my-repo/simple/
# Authenticate
gcloud auth application-default login
# Publish
twine upload \
--repository-url https://us-central1-python.pkg.dev/my-project/my-repo/ \
dist/*Self-hosted devpi
bash
# Install devpi
pip install devpi-server devpi-client
# Start server
devpi-server --start --init
# Create user and index
devpi use http://localhost:3141
devpi user -c myuser password=mypassword
devpi login myuser --password=mypassword
devpi index -c dev
# Upload package
devpi upload dist/*
# Configure pip to use devpi
pip config set global.index-url http://localhost:3141/myuser/dev/+simple/pyproject.toml for Private Registry
toml
# Poetry
[[tool.poetry.source]]
name = "private"
url = "https://my-registry.example.com/simple/"
priority = "primary"
# Or with pip-tools, create pip.conf
# [global]
# index-url = https://my-registry.example.com/simple/
# extra-index-url = https://pypi.org/simple/Semantic Versioning (SemVer)
Version Format
MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
Examples:
1.0.0 # Initial release
1.0.1 # Bug fix
1.1.0 # New feature (backward compatible)
2.0.0 # Breaking change
1.0.0-alpha.1 # Pre-release
1.0.0-beta.2 # Pre-release
1.0.0-rc.1 # Release candidate
1.0.0+build.123 # Build metadataWhen to Bump What
MAJOR (X.0.0) - Breaking Changes
├── Remove public API
├── Change function signatures
├── Change return types
├── Remove deprecated features
└── Incompatible behavior changes
MINOR (0.X.0) - New Features (Backward Compatible)
├── Add new functions/classes
├── Add new optional parameters
├── Deprecate features (but keep working)
└── Add new modules
PATCH (0.0.X) - Bug Fixes (Backward Compatible)
├── Fix bugs
├── Security patches
├── Performance improvements
└── Documentation fixesPre-release Versions
python
# Version ordering (lowest to highest)
# 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0
# In pyproject.toml
[project]
version = "1.0.0a1" # Alpha 1
version = "1.0.0b1" # Beta 1
version = "1.0.0rc1" # Release Candidate 1
version = "1.0.0" # Final releaseVersion Management Tools
bash
# === BUMP2VERSION ===
pip install bump2version
# .bumpversion.cfg
# [bumpversion]
# current_version = 1.0.0
# commit = True
# tag = True
#
# [bumpversion:file:pyproject.toml]
# [bumpversion:file:src/my_package/__init__.py]
bump2version patch # 1.0.0 → 1.0.1
bump2version minor # 1.0.1 → 1.1.0
bump2version major # 1.1.0 → 2.0.0
# === HATCH ===
hatch version patch # 1.0.0 → 1.0.1
hatch version minor # 1.0.1 → 1.1.0
hatch version major # 1.1.0 → 2.0.0
hatch version 2.0.0 # Set specific version
# === POETRY ===
poetry version patch # 1.0.0 → 1.0.1
poetry version minor # 1.0.1 → 1.1.0
poetry version major # 1.1.0 → 2.0.0Dynamic Version from Git Tags
toml
# pyproject.toml with setuptools-scm
[build-system]
requires = ["setuptools>=61.0", "setuptools-scm>=8.0"]
build-backend = "setuptools.build_meta"
[project]
dynamic = ["version"]
[tool.setuptools_scm]
# Version from git tagsbash
# Create release
git tag v1.0.0
git push origin v1.0.0
# Build will use tag as version
python -m build
# Creates: my_package-1.0.0-py3-none-any.whlCI/CD Automation
GitHub Actions
yaml
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
# Required for trusted publishing
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build tools
run: pip install build
- name: Build package
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# No token needed with trusted publishing!Trusted Publishing (Recommended)
yaml
# No API tokens needed!
# Configure on PyPI:
# 1. Go to https://pypi.org/manage/project/my-package/settings/publishing/
# 2. Add GitHub Actions as trusted publisher
# 3. Specify: owner, repository, workflow name
# .github/workflows/publish.yml
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# Automatically uses OIDC tokenGitLab CI
yaml
# .gitlab-ci.yml
stages:
- test
- build
- publish
test:
stage: test
image: python:3.12
script:
- pip install -e ".[dev]"
- pytest
build:
stage: build
image: python:3.12
script:
- pip install build
- python -m build
artifacts:
paths:
- dist/
publish:
stage: publish
image: python:3.12
only:
- tags
script:
- pip install twine
- twine upload dist/*
variables:
TWINE_USERNAME: __token__
TWINE_PASSWORD: $PYPI_TOKENComplete Release Workflow
yaml
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -e ".[dev]"
- name: Run tests
run: pytest --cov=my_package
- name: Type check
run: mypy src/
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build
run: |
pip install build
python -m build
- name: Check
run: |
pip install twine
twine check dist/*
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish-testpypi:
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
publish-pypi:
needs: publish-testpypi
runs-on: ubuntu-latest
permissions:
id-token: write
environment:
name: pypi
url: https://pypi.org/p/my-package
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
github-release:
needs: publish-pypi
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: dist/*
generate_release_notes: trueProduction Pitfalls ⚠️
1. Không test trên TestPyPI trước
bash
# ❌ BAD: Publish thẳng lên PyPI
twine upload dist/*
# Lỗi → Phải bump version để fix!
# ✅ GOOD: Test trên TestPyPI trước
twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ my-package
# Test xong mới publish lên PyPI
twine upload dist/*2. Quên include files trong distribution
toml
# ❌ BAD: Missing files
[tool.setuptools]
packages = ["my_package"]
# py.typed, data files không được include!
# ✅ GOOD: Include all necessary files
[tool.setuptools.package-data]
my_package = [
"py.typed",
"data/*.json",
"templates/*.html",
]
# Hoặc với MANIFEST.in cho sdist
# include src/my_package/py.typed
# recursive-include src/my_package/data *.json3. Hardcode version ở nhiều nơi
python
# ❌ BAD: Version ở nhiều files
# pyproject.toml: version = "1.0.0"
# __init__.py: __version__ = "1.0.0"
# docs/conf.py: version = "1.0.0"
# → Dễ quên update!
# ✅ GOOD: Single source of truth
# pyproject.toml
[project]
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "my_package.__version__"}
# __init__.py
__version__ = "1.0.0"
# docs/conf.py
from my_package import __version__
version = __version__4. Không lock dependencies cho reproducible builds
bash
# ❌ BAD: Build với latest dependencies
pip install build
python -m build
# Dependencies có thể khác nhau mỗi lần build!
# ✅ GOOD: Lock build dependencies
pip install pip-tools
pip-compile --extra build pyproject.toml -o requirements-build.txt
pip install -r requirements-build.txt
python -m build5. Expose sensitive data trong package
python
# ❌ BAD: Hardcoded secrets
API_KEY = "sk-xxxxxxxxxxxxx" # Sẽ được publish!
# ✅ GOOD: Use environment variables
import os
API_KEY = os.environ.get("API_KEY")
# Và thêm vào .gitignore
# .env
# *.pem
# secrets.json6. Không verify package trước khi publish
bash
# ❌ BAD: Publish without verification
python -m build
twine upload dist/*
# ✅ GOOD: Verify everything
python -m build
# Check metadata
twine check dist/*
# Test install locally
pip install dist/*.whl
python -c "import my_package; print(my_package.__version__)"
# Test in clean environment
python -m venv test_env
source test_env/bin/activate
pip install dist/*.whl
python -c "import my_package; my_package.main()"
deactivate
rm -rf test_env
# Now publish
twine upload dist/*Quick Reference
bash
# === BUILD ===
pip install build
python -m build # Build sdist + wheel
python -m build --wheel # Wheel only
# === VERIFY ===
pip install twine
twine check dist/*
# === PUBLISH ===
# TestPyPI
twine upload --repository testpypi dist/*
# PyPI
twine upload dist/*
# === VERSION BUMP ===
# bump2version
bump2version patch/minor/major
# hatch
hatch version patch/minor/major
# poetry
poetry version patch/minor/major
# === INSTALL FROM TESTPYPI ===
pip install --index-url https://test.pypi.org/simple/ my-packageCross-links
- Prerequisites: pyproject.toml - Package configuration
- Prerequisites: Virtual Environments - Environment isolation
- Related: CLI Tools - Build CLI applications
- Related: Production Deployment (Phase 2) - Deploy applications
- Related: Test Architecture - CI/CD integration