Python 패키지를 PyPI에 패킹 및 배포하기

1. 개요

유용한 Python 모듈을 작성했다면 다른 사람들이 pip install로 설치할 수 있게 하고 싶을 텐데요. 그러려면 코드를 distribution으로 패킹한 뒤 PyPI(Python Package Index)에 업로드해야 합니다.

이 글에서는 전체 과정을 살펴봅니다. 표준화된 프로젝트 구조를 만들고, pyproject.toml에서 메타데이터를 선언한 후, wheel과 sdist를 빌드하고, TestPyPI에서 테스트한 다음, 실제로 배포하는 방법을 배우게 됩니다. 마지막으로는 GitHub Actions와 Trusted Publishing을 이용해 배포를 자동화하는 방법을 알아봅시다.

2. 프로젝트 구조

이 글에서는 주문 총액을 계산하는 작은 라이브러리 nonla-order-tools를 예로 들겠습니다. src layout을 사용합니다:

nonla-order-tools/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│   └── nonla_order_tools/
│       ├── __init__.py
│       ├── pricing.py
│       └── cli.py
└── tests/
    └── test_pricing.py

코드가 프로젝트 루트가 아닌 src/ 디렉터리에 위치합니다. 주요 이점은 테스트를 실행할 때 Python이 작업 디렉터리에서 코드를 실수로 import 하지 않는다는 것입니다. 이는 패킹할 파일을 빠뜨린 오류를 조기에 발견하도록 강제합니다.

두 가지 이름을 구분해야 합니다:

  • PyPI의 배포 이름: nonla-order-tools. 사용자가 pip install에서 사용하는 이름입니다.
  • import 패키지 이름: nonla_order_tools. import에서 사용하며 하이픈을 포함할 수 없습니다.

2.1. 라이브러리 코드

# src/nonla_order_tools/pricing.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 must be positive")
        self.lines.append(OrderLine(product, Decimal(unit_price), quantity))


def calculate_total(order: Order, vat_rate: Decimal = Decimal("0.10")) -> Decimal:
    subtotal = sum((l.unit_price * l.quantity for l in order.lines), Decimal("0"))
    return (subtotal * (1 + vat_rate)).quantize(Decimal("1"))

Order는 OrderLine 목록을 포함합니다. calculate_total은 각 라인의 금액을 합산하고, VAT(기본값 10%)를 적용한 후 원 단위로 반올림합니다. 코드는 금액 계산 시 부동소수점 오류를 피하기 위해 float 대신 Decimal을 사용합니다.

# src/nonla_order_tools/__init__.py
from importlib.metadata import version

from .pricing import Order, OrderLine, calculate_total

__all__ = ["Order", "OrderLine", "calculate_total"]
__version__ = version("nonla-order-tools")

__version__은 설치된 패키지의 메타데이터에서 읽습니다. 이렇게 하면 version은 pyproject.toml에 한 곳에서만 선언하면 됩니다.

간단한 CLI 명령어를 추가합니다:

# src/nonla_order_tools/cli.py
import sys
from decimal import Decimal

from .pricing import Order, calculate_total


def main() -> None:
    # 사용법: order-total <가격> <수량> [<가격> <수량> ...]
    args = sys.argv[1:]
    order = Order(customer="cli")
    for price, qty in zip(args[::2], args[1::2]):
        order.add("item", price, int(qty))
    print(calculate_total(order, Decimal("0.10")))

3. pyproject.toml에서 메타데이터 선언

pyproject.toml은 빌드 방법과 패키지 정보를 설명하는 유일한 위치입니다. 새 프로젝트에는 setup.py가 필요 없습니다.

[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"

[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "주문 VAT 포함 총액 계산"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nón Lá Dev", email = "dev@nonla.example" }]
keywords = ["order", "pricing", "vat"]
classifiers = [
    "Programming Language :: Python :: 3",
    "Operating System :: OS Independent",
]
dependencies = []

[project.optional-dependencies]
test = ["pytest>=8"]

[project.scripts]
order-total = "nonla_order_tools.cli:main"

각 섹션의 설명:

  • [build-system]은 pip와 build에게 어떤 build backend를 사용할지 알립니다. 여기서는 hatchling입니다. setuptools, flit-core 또는 pdm-backend를 사용할 수도 있습니다. [project]의 메타데이터는 동일합니다.
  • name과 version은 필수입니다. version은 MAJOR.MINOR.PATCH 형식이어야 합니다.
  • readme는 PyPI의 설명 페이지가 됩니다.
  • requires-python은 지원하지 않는 Python 버전에서의 설치를 차단합니다.
  • license = "MIT"는 새 표준에 따른 SPDX 표현식입니다. 이 표준은 hatchling>=1.27을 필요로 하므로 최소 버전이 위에 명시되어 있습니다.
  • dependencies는 런타임에 필요한 라이브러리를 나열합니다. 예: ["httpx>=0.27"].
  • optional-dependencies는 “extra”를 만듭니다. 사용자는 pip install "nonla-order-tools[test]"로 설치할 수 있습니다.
  • [project.scripts]는 패키지 설치 시 PATH에 order-total 명령어를 만듭니다. 이 명령어는 cli 모듈의 main 함수를 호출합니다.

Hatchling은 정규화된 프로젝트 이름(하이픈을 언더스코어로)과 일치하는 src/nonla_order_tools를 자동으로 찾습니다. 이름이 다르면 수동으로 선언해야 합니다:

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

4. 테스트 작성 및 editable 모드로 설치

배포 전에 주요 동작이 올바른지 확인하세요:

# tests/test_pricing.py
from decimal import Decimal

import pytest

from nonla_order_tools import Order, calculate_total


def test_total_includes_vat():
    order = Order(customer="Lan")
    order.add("Cà phê", "25000", 2)
    order.add("Bánh mì", "20000")

    assert calculate_total(order) == Decimal("77000")


def test_rejects_non_positive_quantity():
    order = Order(customer="Minh")
    with pytest.raises(ValueError, match="positive"):
        order.add("Trà", "15000", 0)

첫 번째 테스트는 (25000 × 2 + 20000) × 1.1 = 77000을 계산합니다. 두 번째 테스트는 수량이 0인 경우 거부되는지 확인합니다.

editable 모드에서 프로젝트를 설치한 후 테스트를 실행합니다:

python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -q
..                                                    [100%]
2 passed in 0.02s

-e 플래그는 설치된 패키지를 src/ 디렉터리와 링크합니다. 코드를 수정하면 즉시 반영되므로 다시 설치할 필요가 없습니다.

5. Wheel과 sdist 빌드

build 도구를 설치한 후 프로젝트 루트에서 실행합니다:

pip install --upgrade build twine
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.27
* Building sdist...
* Building wheel from sdist
Successfully built nonla_order_tools-0.1.0.tar.gz and nonla_order_tools-0.1.0-py3-none-any.whl

dist/ 디렉터리에는 이제 두 개의 파일이 있습니다:

  • sdist (.tar.gz): 메타데이터가 포함된 소스 코드입니다. 호환되는 wheel이 없을 때 사용됩니다.
  • wheel (.whl): 미리 빌드된 배포판입니다. py3-none-any 접미사는 모든 운영체제에서 Python 3으로 작동함을 의미합니다(순수 Python 패키지이므로).

Wheel에 모든 파일이 포함되어 있는지 확인합니다:

python -m zipfile -l dist/nonla_order_tools-0.1.0-py3-none-any.whl
File Name                                             Modified             Size
nonla_order_tools/__init__.py                  2020-02-02 00:00:00          209
nonla_order_tools/cli.py                       2020-02-02 00:00:00          363
nonla_order_tools/pricing.py                   2020-02-02 00:00:00          786
nonla_order_tools-0.1.0.dist-info/METADATA     2020-02-02 00:00:00         1012
nonla_order_tools-0.1.0.dist-info/WHEEL        2020-02-02 00:00:00           87
nonla_order_tools-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00     58
...

고정된 날짜/시간은 의도적입니다: hatchling은 재현 가능한 빌드를 만듭니다. 머신에서의 파일 크기는 약간 다를 수 있습니다.

다음으로 twine을 사용해 메타데이터를 확인하고, 특히 README가 제대로 렌더링되는지 확인합니다:

twine check dist/*
Checking dist/nonla_order_tools-0.1.0-py3-none-any.whl: PASSED
Checking dist/nonla_order_tools-0.1.0.tar.gz: PASSED

업로드 전 마지막 단계는 wheel을 깨끗한 환경에 설치하고 import를 시도하는 것입니다:

python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl
/tmp/verify/bin/python -c "import nonla_order_tools as n; print(n.__version__)"
/tmp/verify/bin/order-total 25000 2 20000 1
0.1.0
77000

이 단계에서 ModuleNotFoundError가 발생하면, 파일을 패킹할 때 빠뜨렸거나 패키지 경로를 잘못 선언했을 가능성이 높습니다.

6. 먼저 TestPyPI에서 테스트

TestPyPI는 PyPI의 독립적인 복사본으로, 테스트 목적으로만 사용됩니다. 두 곳에는 각각 별도의 계정과 token이 있습니다.

  1. TestPyPI에 계정을 만들고 2단계 인증(2FA)을 활성화합니다.
  2. Account settings → API tokens로 이동해 token을 생성합니다. Token은 pypi-로 시작합니다.
  3. 업로드합니다:
twine upload --repository testpypi dist/*

username을 입력하라는 메시지가 나타나면 __token__을, 비밀번호는 token 문자열을 입력합니다. 매번 입력하지 않으려면 ~/.pypirc에 저장할 수 있습니다:

[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...

[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJ...

chmod 600 ~/.pypirc로 권한을 설정하여 머신의 다른 사용자가 파일을 읽지 못하도록 합니다. 그 후 TestPyPI에서 설치를 시도합니다:

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  nonla-order-tools

--extra-index-url 플래그는 pip가 의존성을 실제 PyPI에서 가져올 수 있게 하므로, TestPyPI에는 충분한 패키지가 없을 수 있습니다.

7. PyPI에 배포

모든 것이 정상이면 실제 PyPI의 과정은 동일하며, --repository 플래그만 생략합니다:

twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading nonla_order_tools-0.1.0-py3-none-any.whl
Uploading nonla_order_tools-0.1.0.tar.gz
View at:
https://pypi.org/project/nonla-order-tools/0.1.0/

이제 누구든 pip install nonla-order-tools를 실행할 수 있습니다.

이후 배포의 경우 다음 단계를 따르세요:

  1. pyproject.toml의 version을 증가시킵니다. 예: 0.1.1.
  2. 이전 dist/ 디렉터리를 삭제합니다: rm -rf dist.
  3. python -m build, twine check, 그다음 twine upload를 다시 실행합니다.

주의: PyPI는 같은 이름의 파일을 다시 업로드할 수 없으며, 릴리스를 삭제했더라도 마찬가지입니다. 실수로 잘못된 버전을 배포했다면, 해당 버전을 yank하고 새 버전을 배포할 수만 있습니다. 이것이 항상 먼저 TestPyPI에서 테스트해야 하는 이유입니다.

8. Trusted Publishing으로 자동 배포

장기 token을 CI에 저장하는 것은 보안 위험입니다. Trusted Publishing은 token을 완전히 제거합니다. PyPI는 OIDC를 통해 특정 GitHub Actions 워크플로우를 신뢰하고 각 실행에 대해 단기 token을 발급합니다.

PyPI 설정: 프로젝트의 Publishing 섹션으로 이동하거나, 프로젝트가 아직 없는 경우 “pending publisher”를 사용합니다. 여기서 owner, repository 이름, workflow 파일 이름 publish.yml과 환경 pypi를 선언합니다.

# .github/workflows/publish.yml
name: Publish to PyPI

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python -m pip install build
      - run: python -m build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

워크플로우의 작동 방식:

  • 워크플로우는 GitHub Release를 생성할 때마다 실행됩니다.
  • build job은 dist/에 파일을 생성하고 이를 artifact로 저장합니다.
  • publish job은 별도로 실행되며 id-token: write 권한만 있습니다. 이 권한은 OIDC token을 요청할 수 있게 하고, pypa/gh-action-pypi-publish action이 token을 사용해 업로드합니다. 어떤 secret도 필요 없습니다.
  • 두 job을 분리하면 빌드 단계(코드와 빌드 의존성을 실행)가 배포 권한에 결코 접근할 수 없습니다.

9. 자주 마주치는 오류

  • 이름이 이미 사용됨: PyPI에서 403 또는 The name is too similar to an existing project가 표시됩니다. 이름을 미리 확인하고 nonla- 같은 고유 접두사를 추가하는 것이 좋습니다.
  • File already exists: version을 올리지 않았거나 dist/ 디렉터리에 이전 파일이 남아 있습니다. 빌드 전에 항상 dist/를 삭제하세요.
  • 데이터 파일이 없음 (JSON, 템플릿…): 파일이 패키지 디렉터리 내에 없으면 패킹되지 않습니다. 이들을 src/nonla_order_tools/에 배치하고 importlib.resources로 읽으세요.
  • 테스트는 통과하지만 설치 후 import 오류: 이것이 정확히 src layout이 방지하는 오류입니다. 항상 깨끗한 venv에 wheel을 설치해보세요(5번 섹션 참조).
  • 너무 엄격한 의존성 선언: 라이브러리는 requests==2.32.3 같이 버전을 고정해서는 안 되며, 사용자의 프로젝트와 충돌을 일으킵니다. requests>=2.31 같은 하한만 설정하세요. 버전 고정은 애플리케이션에만 적합하며 lock 파일을 통해 해야 합니다.
  • Token 유출: 절대 ~/.pypirc 또는 token을 repository에 commit하지 마세요. project별 제한된 token을 사용하거나, 더 좋게는 Trusted Publishing을 사용하세요.

주의: 모든 코드를 PyPI에 올려야 하는 것은 아닙니다. 회사 내부 라이브러리는 private index(Artifactory, GitLab Package Registry 등)를 사용하거나 Git에서 직접 설치해야 합니다. PyPI는 공개 공간이고, 이미 점유된 이름을 돌려받기는 매우 어렵습니다.

10. 결론

현대 Python 패킹 프로세스는 상당히 간단합니다. 모든 것이 하나의 pyproject.toml 파일, hatchling 같은 build backend, python -m build로 wheel과 sdist를 생성하고, twine으로 확인하고 업로드하는 것 중심입니다.

다음 순서를 습관화하세요: 테스트 실행 → 빌드 → 깨끗한 venv에 설치 → TestPyPI에서 테스트 → PyPI에 배포. 프로젝트가 안정화되면 GitHub Actions와 Trusted Publishing으로 전환하세요. 이후 배포는 Release를 만드는 것만으로 충분하고, token을 더 이상 관리할 필요가 없습니다.

pythonpypipackagingpyprojectgithub-actions