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

Сравнение flat layout и src layout: в flat пакет лежит в корне рядом с tests, в src layout пакет вложен в src/ что защищает от случайного импорта из CWD

Преимущества 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 point pytest = "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 и сборкой в одном
hatchModern инструмент с матрицами окружений
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 стало проще и стандартизированнее.

Мини-задание

  1. Минимальный пакет:
# 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!
  1. Сборка:
pip install build
python -m build
ls dist/
# greet_tool-0.1.0.tar.gz  greet_tool-0.1.0-py3-none-any.whl
  1. Optional dependencies:
[project.optional-dependencies]
dev = ["pytest>=8", "mypy>=1.8"]
pip install -e ".[dev]"   # ставит проект + dev deps

Что дальше

Освоили packaging. В следующем уроке - тур по stdlib: основные модули стандартной библиотеки для backend-задач: os, pathlib, subprocess, shutil.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.