목차
5부 운영 · 패키징 · 테스트
  1. 30.타입체커 설정과 CI 통합
  2. 31.logging과 관측성
  3. 32.uv로 라이브러리 배포 — pyproject.toml과 PyPI 출시
  4. 33.CLI 도구 만들기 (Typer)
32 장

uv로 라이브러리 배포 — pyproject.toml과 PyPI 출시

pyproject.toml의 의미를 한 번에 정리하고 uv build · uv publish로 첫 라이브러리를 PyPI에 출시하는 과정을 다룹니다.

7장 모듈과 pyproject.toml에서 본 pyproject.toml은 “내 프로젝트를 실행하기 위한 설정” 시점이었습니다. 이번 챕터는 같은 파일을 “내 라이브러리를 남이 쓸 수 있게 PyPI에 출시하기 위한 설정” 시점으로 다시 보겠습니다. 그리고 그 흐름을 uv 한 도구로 끝내보겠습니다.

여러분이 33장 CLI 도구 만들기에서 만들 CLI를 본 챕터의 흐름으로 PyPI에 출시할 수 있습니다 — 두 챕터가 한 묶음입니다.

왜 직접 배포해 보는가 #

라이브러리를 출시할 일이 없어 보여도, 직접 한 번 해 봐야 다른 PyPI 패키지가 왜 그 구조로 돼 있는지가 보입니다. 의존성을 어떻게 선언하나, README가 PyPI 페이지에 어떻게 보이나, wheel과 sdist가 무엇이며 왜 둘 다 올리나 — 이 모든 게 직접 해 보고 나서야 자연스럽게 머리에 남습니다.

pyproject.toml 표준 — 무엇이 어디로 가는가 #

pyproject.toml은 여러 PEP가 누적된 결과입니다.

섹션PEP역할
[build-system]PEP 518패키지를 빌드할 때 어떤 도구가 필요한가
[project]PEP 621패키지의 메타데이터 (이름, 버전, 저자, 의존성)
[project.scripts]PEP 621CLI 엔트리포인트
[project.optional-dependencies]PEP 621선택 의존성 그룹
[dependency-groups]PEP 735개발용 의존성 그룹 (uv / pip가 지원)
[tool.<name>]도구 자유각 도구의 설정 (ruff, pyright, pytest 등)

최소 예제 #

pyproject.toml — 라이브러리용 최소 형태
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "swcli"
version = "0.1.0"
description = "Schoolofweb sample CLI"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [
  { name = "Your Name", email = "you@example.com" },
]
keywords = ["cli", "example"]
classifiers = [
  "Programming Language :: Python :: 3",
  "Programming Language :: Python :: 3 :: Only",
  "License :: OSI Approved :: MIT License",
  "Operating System :: OS Independent",
]
dependencies = [
  "typer>=0.12",
  "rich>=13",
]

[project.optional-dependencies]
yaml = ["pyyaml>=6"]

[project.urls]
Homepage = "https://github.com/you/swcli"
Issues = "https://github.com/you/swcli/issues"

[project.scripts]
swcli = "swcli.cli:app"

[dependency-groups]
dev = [
  "pytest>=8",
  "pytest-cov",
  "ruff",
  "pyright",
]

핵심 필드 의미 #

  • name — PyPI의 고유 이름. 일단 등록되면 비슷한 이름도 막힙니다 (typosquatting 방지).
  • version — SemVer 권장 (MAJOR.MINOR.PATCH).
  • requires-python — 어떤 Python 버전부터 동작하는가. PyPI가 호환 안 되는 wheel을 자동 필터링.
  • dependencies — 런타임에 반드시 필요한 의존성. 범위 지정이 핵심 (뒤 절에서).
  • optional-dependencies — 사용자가 pip install swcli[yaml]처럼 추가로 받을 수 있는 묶음.
  • dependency-groups.dev — 개발자만 쓰는 의존성. 배포 wheel에는 포함되지 않음.
  • project.scripts — 설치하면 swcli라는 명령이 PATH에 생김. 우항은 <module>:<callable>.

src layout vs flat layout #

라이브러리의 디렉터리 구조에는 두 가지 관행이 있습니다.

flat layout

myproject/
  pyproject.toml
  swcli/
    __init__.py
    cli.py
  tests/

src layout

myproject/
  pyproject.toml
  src/
    swcli/
      __init__.py
      cli.py
  tests/

src layout이 권장입니다. 이유:

  • cd myproject; python -c "import swcli"이 우연히 동작하지 않습니다 — 설치하지 않으면 import 안 됨. 그래서 “설치되지 않은 상태에서도 어쩌다 동작하는 버그"가 생기지 않습니다.
  • 테스트는 항상 “설치된 패키지"를 import 하니, 사용자의 경험과 일치합니다.

src layout을 쓰면 빌드 백엔드 (hatchling 등)가 자동으로 src/를 찾습니다.

의존성 선언 — 범위 지정의 원칙 #

dependenciesrequests라고만 쓰면 “어떤 requests 버전이든 OK"입니다. 그러면 1년 뒤 사용자가 설치할 때 호환 안 되는 신버전이 깔려 깨질 수 있습니다.

라이브러리애플리케이션의 정책이 다릅니다.

  • 라이브러리 (PyPI 배포 대상)하한과 상한을 명시. requests>=2.31,<3. 상한은 메이저 버전.
  • 애플리케이션 (uv.lock으로 고정 배포) — uv.lock이 정확한 버전을 고정하므로 범위는 더 너그러워도 됨.

본 챕터는 라이브러리 시점이니 다음 형식을 권장합니다.

dependencies = [
  "typer>=0.12,<1",
  "rich>=13,<15",
]

상한이 없으면 의존성의 메이저 업데이트가 silent 하게 들어와 사용자 환경을 깨뜨릴 수 있습니다.

uv build — wheel과 sdist 만들기 #

uv build

dist/ 폴더에 두 파일이 생깁니다.

dist/
  swcli-0.1.0-py3-none-any.whl
  swcli-0.1.0.tar.gz
형식정체
wheel (.whl)빌드된 바이너리 형태. 설치가 빠름. 이름에 호환 태그 (py3-none-any)가 들어감
sdist (.tar.gz)소스 배포. 빌드 전 상태. 사용자 환경에서 빌드 가능

순수 Python 라이브러리는 둘 다 올리는 것이 표준입니다. C 확장이 있는 패키지는 플랫폼별 wheel을 여러 개 (manylinux_2_28_x86_64, macosx_11_0_arm64 등) 만들어 올립니다.

TestPyPI로 리허설 #

처음 출시는 TestPyPI에 먼저 올립니다 — 실수해도 본 PyPI가 더럽혀지지 않습니다.

계정과 토큰 #

  1. https://test.pypi.org/account/register/ 에서 가입.
  2. https://test.pypi.org/manage/account/token/ 에서 API token 발급. 이름은 단일 프로젝트로 좁히는 것이 권장 (전체 권한 토큰을 환경변수로 두지 마세요).

환경변수로 전달 #

export UV_PUBLISH_TOKEN="pypi-AgENd..."
export UV_PUBLISH_URL="https://test.pypi.org/legacy/"

uv build
uv publish

성공하면 https://test.pypi.org/project/swcli/에서 확인 가능합니다.

설치 테스트 #

uv pip install --index-url https://test.pypi.org/simple/ \
               --extra-index-url https://pypi.org/simple/ \
               swcli

--extra-index-url을 추가하는 이유는 TestPyPI에 의존성 패키지가 없을 수 있어 본 PyPI에서 받아오기 위해서입니다.

uv publish — 본 PyPI 출시 과정 #

리허설이 끝났으면 본 PyPI로.

unset UV_PUBLISH_URL    # 본 PyPI 가 기본값
export UV_PUBLISH_TOKEN="pypi-AgEN..."   # pypi.org 의 토큰

uv build
uv publish

https://pypi.org/project/swcli/에서 확인.

설치:

uv pip install swcli
swcli --help

축하합니다 — 라이브러리가 세상에 나왔습니다.

한 번 올린 버전은 못 지운다 #

PyPI의 가장 중요한 원칙입니다. 같은 버전을 다시 올리는 건 거부 되며, 잘못 올린 버전은 삭제는 가능하지만 그 자리에 같은 버전을 재업로드할 수 없습니다. 사용자의 의존성 결정자 (resolver)가 일관성을 유지하기 위해서입니다.

실수했다면 버전을 올려 (0.1.00.1.1) 새로 출시합니다. 그래서 TestPyPI 리허설이 중요 합니다.

SemVer와 CHANGELOG #

Semantic Versioning #

MAJOR.MINOR.PATCH의 의미는 다음과 같습니다.

변경 종류올릴 버전
깨지는 변경 (signature 변경, 제거)MAJOR
호환되는 새 기능 추가MINOR
버그 수정 (행동 동일)PATCH

0.x는 “아직 안정 API 아님"의 합의입니다. 이 구간에선 MINOR가 사실상 MAJOR처럼 깨지는 변경을 포함할 수 있습니다. 1.0.0을 찍으면 그때부터 위 규칙을 엄격히 지킵니다.

CHANGELOG.md #

Keep a Changelog 형식이 표준입니다.

CHANGELOG.md
# Changelog

## [Unreleased]

## [0.2.0] - 2026-06-01
### Added
- `--json` 출력 옵션 추가.

### Fixed
- Windows 에서 색 코드가 그대로 출력되던 문제.

## [0.1.0] - 2026-05-17
- 첫 출시.

PR 머지 때마다 [Unreleased]에 한 줄씩 적고, 출시 직전에 버전 섹션으로 옮기는 흐름입니다.

README · LICENSE · 메타데이터 #

PyPI의 패키지 페이지는 다음을 보여줍니다.

  • README.md — 페이지의 본문. 첫 화면이라 5초 안에 “무엇인가” + “왜 쓰나” + “어떻게 시작하나"가 보여야 함.
  • License[project] license = "MIT" (SPDX 식별자). 없으면 사람들이 못 씁니다 (기본은 사용 금지).
  • classifiers — PyPI의 분류 태그. 본 책의 카테고리/주제/Python 버전이 검색에 영향.
  • Homepage / Repository / Issues URL — 사이드바 링크.

README에 **버지 (badge)**를 다는 것도 신뢰 신호입니다.

![CI](https://github.com/you/swcli/actions/workflows/ci.yml/badge.svg)
![PyPI](https://img.shields.io/pypi/v/swcli)
![Python](https://img.shields.io/pypi/pyversions/swcli)

GitHub Actions로 태그 푸시 자동 배포 #

매번 손으로 uv build && uv publish 하는 건 실수의 원천입니다. Git 태그를 푸시하면 자동 배포 되게 합니다.

Trusted Publishing — 토큰이 필요 없는 방식 #

PyPI의 신규 권장 방식입니다. GitHub Actions의 OIDC 토큰으로 PyPI에 인증합니다. API 토큰을 secrets에 저장할 필요가 없어 가장 안전 합니다.

PyPI 측 설정:

  1. https://pypi.org/manage/project/<name>/settings/publishing/으로 이동.
  2. “Add a new publisher” → GitHub 선택.
  3. owner, repository, workflow filename, environment name 입력.

워크플로우 파일:

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

on:
  push:
    tags:
      - "v*"

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v4
      - run: uv python install 3.14
      - run: uv build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi             # PyPI 측 설정과 일치
    permissions:
      id-token: write             # OIDC 토큰 발급에 필요
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

태그 푸시:

git tag v0.2.0
git push origin v0.2.0

GitHub Actions가 빌드 → OIDC로 PyPI 인증 → publish까지 자동으로 처리합니다.

흔한 함정 #

같은 버전을 다시 올리려고 시도 #

이미 본 위 절. PyPI는 거부합니다. 버전을 올려 새로 출시.

의존성 상한 누락 #

dependencies = ["pydantic"]   # 비권장
dependencies = ["pydantic>=2,<3"]   # 권장

상한이 없으면 의존성의 메이저 업데이트가 사용자 환경을 silent 하게 깨뜨릴 수 있습니다.

LICENSE 누락 #

license가 비어 있거나 잘못된 식별자면 PyPI가 경고하거나 회사 정책에 따라 사내 사용이 막힙니다. 가능한 한 SPDX 식별자 (MIT, Apache-2.0, BSD-3-Clause 등)를 사용합니다.

큰 파일을 sdist에 포함 #

.gitignoretool.hatch.build.targets.sdist.exclude가 다릅니다. 빌드 결과를 한 번 열어 (unzip dist/*.whl -d /tmp/inspect; ls -lh /tmp/inspect) 확인하는 것이 안전합니다.

CLI 엔트리포인트의 잘못된 경로 #

project.scripts의 우항은 모듈 경로입니다.

swcli = "swcli.cli:app"   # OK — src/swcli/cli.py 의 app
swcli = "src.swcli.cli:app"   # 틀림 — src 는 패키지가 아님

설치 후 swcli --help가 “module not found"로 실패하면 이 경로를 의심합니다.

TestPyPI와 본 PyPI의 토큰을 헷갈리기 #

도메인이 다르고 토큰도 다릅니다. 자동화에서는 환경별로 secret을 분리해 둡니다 (PYPI_TOKEN, TEST_PYPI_TOKEN).

33장과 묶기 — Typer CLI 출시 #

33장에서 만들 Typer 기반 CLI를 본 챕터의 흐름으로 그대로 출시할 수 있습니다.

swcli/
  pyproject.toml         ← 본 챕터
  src/swcli/
    __init__.py
    cli.py               ← 33장
  tests/
  README.md
  CHANGELOG.md
  .github/workflows/
    ci.yml               ← 30장
    publish.yml          ← 본 챕터

30장의 CI가 통과한 코드만 main에 들어오고, main에 태그가 푸시되면 자동으로 PyPI에 출시되는 구조입니다.

연습문제 #

  1. wheel 안 들여다보기 — 본 챕터의 흐름으로 만든 dist/<name>-0.1.0-py3-none-any.whlunzip으로 풀어 보세요. 무엇이 들어 있나요? METADATA 파일을 열어 본인이 pyproject.toml에 적은 내용이 그대로 들어갔는지 확인하고, 빠진 항목 (예: classifier의 오타)이 있는지 확인하세요.
  2. 의존성 범위의 영향 — pyproject.toml의 한 의존성을 pydantic>=2 (상한 없음)로 바꿔 빌드한 wheel을 설치한 환경에서 uv add pydantic==3.0.0a1 (가상 신버전) 같은 prerelease를 받아 보세요. resolver가 어떻게 동작하나요? 상한 <3을 다시 추가하면 결과가 어떻게 달라지나요?
  3. Trusted Publishing 설정 — 본인의 GitHub repo와 PyPI 계정에 Trusted Publishing을 직접 설정해 v0.0.1을 한 번 출시해 보세요. permissions: id-token: write가 빠지면 어떤 에러가 나나요? environment: pypi가 PyPI의 설정과 다르면 어떤 에러가 나나요?
노트
한 줄 요약 — pyproject.toml 한 파일에 메타데이터·의존성·CLI 엔트리포인트를 모두 선언하고, uv build로 wheel/sdist를 만들고, TestPyPI로 리허설한 뒤 본 PyPI로 출시합니다. 자동화는 GitHub Actions + Trusted Publishing으로 — 토큰을 보관하지 않는 것이 가장 안전한 답입니다.

다음 챕터는 CLI 도구 만들기 (Typer)입니다. 본 챕터의 흐름으로 출시할 그 패키지를 실제로 만드는 단계입니다.

X