Packaging: pyproject.toml, build, publish to PyPI
Packaging: pyproject.toml, build, publish to PyPI
Packaging превращает твой код в распространяемый пакет. Это нужно: для публикации библиотек на PyPI, для распространения внутри компании, для создания CLI-утилит, для установки в Docker-образ. В этом уроке - современный workflow на основе pyproject.toml, сборка wheel, публикация и entry points для CLI.
Эволюция packaging в Python
Краткая история:
- setup.py (2003+) - программный скрипт сборки, доминировал до 2020-х
- setup.cfg (~2015+) - декларативная альтернатива setup.py
- pyproject.toml (PEP 518, 2016) - новый стандарт, унифицированный config
- PEP 621 (2020) - стандартизированные
[project]метаданные в pyproject.toml
В 2026 году новые проекты используют только pyproject.toml. setup.py и setup.cfg всё ещё работают для backward compatibility, но не нужны.
Минимальный pyproject.toml
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "my-package"
version = "0.1.0"
description = "Краткое описание"
requires-python = ">=3.10"
dependencies = [
"requests>=2.30",
]
Этого достаточно для базового пакета. Запускается pip install . или pip install -e . (editable).
Полный pyproject.toml
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "my-package"
version = "0.1.0"
description = "REST API for task management"
readme = "README.md"
license = {text = "MIT"}
authors = [
{name = "Alice", email = "alice@example.com"},
]
keywords = ["api", "rest", "fastapi"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.110",
"pydantic>=2.6",
"uvicorn[standard]>=0.27",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"mypy>=1.8",
"ruff>=0.3",
]
test = [
"pytest>=8.0",
"httpx>=0.27",
]
[project.urls]
Homepage = "https://github.com/me/my-package"
Documentation = "https://my-package.readthedocs.io"
Issues = "https://github.com/me/my-package/issues"
[project.scripts]
my-cli = "my_package.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
Разделы:
[build-system]- какой builder использовать (setuptools, hatchling, poetry-core, flit)[project]- PEP 621 метаданные (имя, версия, deps, классификаторы)[project.optional-dependencies]- extras для разных сценариев[project.urls]- ссылки (отображаются на PyPI)[project.scripts]- CLI-команды (entry points)[tool.<tool_name>]- конфиг конкретного инструмента (setuptools, mypy и ruff, etc.)
Build backends
build-system.build-backend определяет как пакет собирается:
- setuptools - классический, самый совместимый
- hatchling - современный от Hatch, часто рекомендуется
- poetry-core - если используешь poetry
- flit - минималистичный
- pdm-backend - для PDM
Для большинства проектов setuptools или hatchling подходят. Выбор скорее по личным предпочтениям инструмента сборки.
src layout vs flat layout
Преимущества src layout:
- Невозможно случайно импортировать неустановленный пакет из CWD
- Чёткое разделение source и tests
- Лучше проверка что установка работает корректно
Современная практика - src layout. Для setuptools конфиг:
[tool.setuptools.packages.find]
where = ["src"]
Сборка пакета
pip install build
python -m build
Создаёт в dist/:
my_package-0.1.0.tar.gz- source distribution (sdist)my_package-0.1.0-py3-none-any.whl- wheel (binary)
Wheel это zip-архив со скомпилированным содержимым - быстрая установка. sdist это исходники, собираются при установке. Оба нужны для разных сценариев.
Установка из локального wheel
pip install dist/my_package-0.1.0-py3-none-any.whl
Публикация на PyPI
pip install twine
twine upload dist/*
Запросит логин/пароль (или token). Аккаунт нужно зарегистрировать на pypi.org. Версия должна быть уникальной - повторно загружать ту же версию нельзя (по политике PyPI).
Для тестов есть TestPyPI - отдельный sandbox:
twine upload --repository testpypi dist/*
Тестировать перед публикацией на основной PyPI обязательно.
Версионирование
Семантическое версионирование (SemVer):
MAJOR.MINOR.PATCH
1.0.0 - первый стабильный релиз
1.0.1 - bugfix (backward compatible)
1.1.0 - новая фича (backward compatible)
2.0.0 - breaking change
Pre-release:
1.0.0a1 - alpha
1.0.0b1 - beta
1.0.0rc1 - release candidate
1.0.0.dev1 - dev build
Версия указывается в pyproject.toml:
[project]
version = "1.0.0"
Или динамически через атрибут пакета:
[project]
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "my_package.__version__"}
Entry points для CLI
[project.scripts] создаёт CLI-команды при установке пакета:
[project.scripts]
my-cli = "my_package.cli:main"
Это вызовет my_package.cli.main() когда пользователь выполнит my-cli в терминале.
# my_package/cli.py
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument("name")
args = parser.parse_args()
print(f"Hello, {args.name}")
После pip install my-package в системе появится команда my-cli.
Пример из стандартных пакетов:
pytest- entry pointpytest = "pytest:console_main"black-black = "black:patched_main"mypy-mypy = "mypy.__main__:console_entry"
Manifest и не-Python файлы
Если пакет содержит data-файлы (templates, schemas, static):
[tool.setuptools.package-data]
my_package = ["data/*.json", "templates/*.html"]
Файлы будут включены в wheel и доступны через importlib.resources:
import importlib.resources
with importlib.resources.files("my_package").joinpath("data/config.json").open() as f:
config = json.load(f)
Локальные зависимости
dependencies = [
"my-local-pkg @ file:///absolute/path/to/pkg",
"from-git @ git+https://github.com/me/pkg.git@v1.0",
]
Это работает, но не для PyPI - туда такие пакеты не загружаются. Полезно для проектов с private dependencies.
Build isolation
python -m build создаёт изолированное окружение для сборки. Это значит:
- Можешь явно указать зависимости сборки в
[build-system].requires - Build process не зависит от глобально установленных пакетов
- Воспроизводимо в разных окружениях
Это решает проблемы старых setup.py где build process мог требовать установки wheel или специфичных версий setuptools.
Альтернативные инструменты
| Tool | Описание |
|---|---|
| pip + build + setuptools | Стандарт, работает везде |
| poetry | Менеджер с dependency resolution и сборкой в одном |
| hatch | Modern инструмент с матрицами окружений |
| pdm | Менеджер с PEP 582 поддержкой |
| uv | Очень быстрая замена pip от Astral |
| flit | Минималистичный для простых проектов |
Для нового проекта в 2026: uv или poetry - удобный workflow + быстрая работа. setuptools + pip - всегда работает но больше boilerplate.
CI/CD пример
Простой GitHub Actions для публикации при тэге:
name: Publish
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build twine
- run: python -m build
- run: twine upload dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
При создании тэга v1.0.0 автоматически собирается и публикуется.
Распространённые ошибки
1. Версия не обновлена
version = "1.0.0" # уже опубликовано
PyPI не разрешает перезалить ту же версию. Всегда инкрементируй перед загрузкой.
2. Забыл pyproject.toml в .gitignore
.gitignore:
dist/
build/
*.egg-info/
__pycache__/
.venv/
Build артефакты не должны быть в git. Только pyproject.toml и исходники.
3. Не указал python_requires
[project]
requires-python = ">=3.10" # без этого может быть установлен на старый Python
Без requires-python pip может установить пакет на Python где он не работает.
4. Слишком слабые ограничения зависимостей
dependencies = ["requests"] # любая версия
Лучше указывать минимум:
dependencies = ["requests>=2.30"]
Для библиотек не пинуй жёстко (==2.30.0) - пользователи не смогут обновить. Для приложений можно использовать lock-file (poetry.lock) для воспроизводимости.
5. Большие файлы в пакете
MANIFEST.in или package-data могут случайно включить большие файлы. Проверяй размер pip download my-package или pip show -f my-package.
Внутренние пакеты компании
Если хочешь распространять пакет внутри компании без публичного PyPI:
- Приватный PyPI server (Nexus, Artifactory, JFrog)
- Git-зависимости в pyproject.toml
- Платформа типа AWS CodeArtifact, GCP Artifact Registry
Установка из приватного PyPI:
pip install --index-url https://pypi.company.com my-internal-pkg
Или в pyproject.toml:
[[tool.pdm.source]] # для PDM, аналог для poetry/pip
url = "https://pypi.company.com/simple"
Сравнение с Go и PHP
В Go нет аналога PyPI - используется git напрямую:
import "github.com/me/mypackage"
go mod автоматически качает и кеширует. Версии через git tags.
В PHP - Composer с Packagist (аналог PyPI):
composer require vendor/package
composer.json похож на pyproject.toml по идеологии.
Python packaging исторически сложнее (legacy от setuptools), но в 2026 году с pyproject.toml стало проще и стандартизированнее.
Мини-задание
- Минимальный пакет:
# pyproject.toml
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "greet-tool"
version = "0.1.0"
description = "Greeting CLI tool"
requires-python = ">=3.10"
[project.scripts]
greet = "greet_tool.cli:main"
# src/greet_tool/__init__.py
__version__ = "0.1.0"
# src/greet_tool/cli.py
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument("name", default="World", nargs="?")
args = parser.parse_args()
print(f"Hello, {args.name}!")
pip install -e .
greet Alice # Hello, Alice!
- Сборка:
pip install build
python -m build
ls dist/
# greet_tool-0.1.0.tar.gz greet_tool-0.1.0-py3-none-any.whl
- Optional dependencies:
[project.optional-dependencies]
dev = ["pytest>=8", "mypy>=1.8"]
pip install -e ".[dev]" # ставит проект + dev deps
Что дальше
Освоили packaging. В следующем уроке - тур по stdlib: основные модули стандартной библиотеки для backend-задач: os, pathlib, subprocess, shutil.