Skip to content

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

FeatureSource Distribution (sdist)Wheel (.whl)
Format.tar.gz.whl (ZIP)
ContainsSource codePre-built code
Build required✅ Yes❌ No
Install speedSlowFast
C extensionsNeeds compilerPre-compiled
PlatformAnyPlatform-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.whl

Building Packages

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.whl

Using 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.whl

Using Hatch

bash
# Build with Hatch
hatch build

# Build specific format
hatch build --target wheel
hatch build --target sdist

Using 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: PASSED

Publishing to PyPI

PyPI Account Setup

  1. Create account: https://pypi.org/account/register/
  2. Enable 2FA: Required for new projects
  3. 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 ~/.pypirc

Publishing 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 --build

Publishing with Hatch

bash
# Publish with Hatch
hatch publish

# Publish to TestPyPI
hatch publish --repo test

Publishing with uv

bash
# Publish with uv
uv publish

# With token
uv publish --token pypi-xxxxxxxxxxxxx

Private Registries

Why Private Registries?

  • 🔒 Internal packages không public
  • 🏢 Company-specific libraries
  • 🔐 Proprietary code
  • 📦 Pre-release testing

Options for Private Registries

SolutionHostingCostFeatures
PyPI (private)CloudFree (limited)Basic
AWS CodeArtifactAWSPay-per-useAWS integration
GCP Artifact RegistryGCPPay-per-useGCP integration
Azure ArtifactsAzurePay-per-useAzure integration
JFrog ArtifactorySelf/Cloud$$Enterprise
Nexus RepositorySelf-hostedFree/$Full-featured
devpiSelf-hostedFreeSimple

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 metadata

When 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 fixes

Pre-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 release

Version 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.0

Dynamic 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 tags
bash
# 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.whl

CI/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!
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 token

GitLab 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_TOKEN

Complete 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: true

Production 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 *.json

3. 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 build

5. 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.json

6. 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-package