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 621 | CLI 엔트리포인트 |
[project.optional-dependencies] | PEP 621 | 선택 의존성 그룹 |
[dependency-groups] | PEP 735 | 개발용 의존성 그룹 (uv / pip가 지원) |
[tool.<name>] | 도구 자유 | 각 도구의 설정 (ruff, pyright, pytest 등) |
최소 예제 #
[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/를 찾습니다.
의존성 선언 — 범위 지정의 원칙 #
dependencies에 requests라고만 쓰면 “어떤 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 builddist/ 폴더에 두 파일이 생깁니다.
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가 더럽혀지지 않습니다.
계정과 토큰 #
- https://test.pypi.org/account/register/ 에서 가입.
- 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 publishhttps://pypi.org/project/swcli/에서 확인.
설치:
uv pip install swcli
swcli --help축하합니다 — 라이브러리가 세상에 나왔습니다.
한 번 올린 버전은 못 지운다 #
PyPI의 가장 중요한 원칙입니다. 같은 버전을 다시 올리는 건 거부 되며, 잘못 올린 버전은 삭제는 가능하지만 그 자리에 같은 버전을 재업로드할 수 없습니다. 사용자의 의존성 결정자 (resolver)가 일관성을 유지하기 위해서입니다.
실수했다면 버전을 올려 (0.1.0 → 0.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
## [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)**를 다는 것도 신뢰 신호입니다.


GitHub Actions로 태그 푸시 자동 배포 #
매번 손으로 uv build && uv publish 하는 건 실수의 원천입니다. Git 태그를 푸시하면 자동 배포 되게 합니다.
Trusted Publishing — 토큰이 필요 없는 방식 #
PyPI의 신규 권장 방식입니다. GitHub Actions의 OIDC 토큰으로 PyPI에 인증합니다. API 토큰을 secrets에 저장할 필요가 없어 가장 안전 합니다.
PyPI 측 설정:
https://pypi.org/manage/project/<name>/settings/publishing/으로 이동.- “Add a new publisher” → GitHub 선택.
- owner, repository, workflow filename, environment name 입력.
워크플로우 파일:
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.0GitHub Actions가 빌드 → OIDC로 PyPI 인증 → publish까지 자동으로 처리합니다.
흔한 함정 #
같은 버전을 다시 올리려고 시도 #
이미 본 위 절. PyPI는 거부합니다. 버전을 올려 새로 출시.
의존성 상한 누락 #
dependencies = ["pydantic"] # 비권장
dependencies = ["pydantic>=2,<3"] # 권장상한이 없으면 의존성의 메이저 업데이트가 사용자 환경을 silent 하게 깨뜨릴 수 있습니다.
LICENSE 누락 #
license가 비어 있거나 잘못된 식별자면 PyPI가 경고하거나 회사 정책에 따라 사내 사용이 막힙니다. 가능한 한 SPDX 식별자 (MIT, Apache-2.0, BSD-3-Clause 등)를 사용합니다.
큰 파일을 sdist에 포함 #
.gitignore와 tool.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에 출시되는 구조입니다.
연습문제 #
- wheel 안 들여다보기 — 본 챕터의 흐름으로 만든
dist/<name>-0.1.0-py3-none-any.whl을unzip으로 풀어 보세요. 무엇이 들어 있나요?METADATA파일을 열어 본인이 pyproject.toml에 적은 내용이 그대로 들어갔는지 확인하고, 빠진 항목 (예: classifier의 오타)이 있는지 확인하세요. - 의존성 범위의 영향 — pyproject.toml의 한 의존성을
pydantic>=2(상한 없음)로 바꿔 빌드한 wheel을 설치한 환경에서uv add pydantic==3.0.0a1(가상 신버전) 같은 prerelease를 받아 보세요. resolver가 어떻게 동작하나요? 상한<3을 다시 추가하면 결과가 어떻게 달라지나요? - Trusted Publishing 설정 — 본인의 GitHub repo와 PyPI 계정에 Trusted Publishing을 직접 설정해 v0.0.1을 한 번 출시해 보세요.
permissions: id-token: write가 빠지면 어떤 에러가 나나요?environment: pypi가 PyPI의 설정과 다르면 어떤 에러가 나나요?
다음 챕터는 CLI 도구 만들기 (Typer)입니다. 본 챕터의 흐름으로 출시할 그 패키지를 실제로 만드는 단계입니다.