Learning Platform
Урок 03.02 · 20 мин
Начальный
pyproject.tomlPEP 621DependenciesLockfileVersioning
pyproject.toml: публикация пакета и build-backend

Где живёт описание проекта

В прошлом уроке мы разобрали uv как инструмент. Теперь — про файл, в который uv всё записывает: pyproject.toml. Это

manifest
Python-проекта. Без него у вас просто набор .py-файлов, с ним — установимый, тестируемый, упаковываемый продукт.

Юниор смотрит на pyproject.toml как на «эту штуку, в которую uv что-то пишет». Через год вы будете править его осознанно — добавлять источники, настраивать линтер, разводить зависимости по группам. К этому моменту нужно понимать каждую секцию.

Краткая история

До 2017 года Python не имел стандарта на манифест. Использовали:

  • setup.py — исполняемый Python-скрипт, описывающий пакет. Проблема: для сборки пакета нужно было запустить произвольный код, и установка пакета могла сделать что угодно с системой (включая os.system("rm -rf /") — реальный страх 2010-х).
  • setup.cfg — статическая альтернатива, но не до конца стандарт.
  • requirements.txt — простой список зависимостей, без метаданных проекта.
  • Pipfile и Pipfile.lock от инструмента pipenv — попытка стандартизации, не взлетела.

В 2016-2017 годах вышли три ключевых

PEP
:

  • PEP 518 — ввёл сам файл pyproject.toml как стандарт для конфигурации сборки.
  • PEP 517 — описал, как build-системы (setuptools, hatchling, poetry-core) подключаются через этот файл.
  • PEP 621 (2020) — стандартизировал секцию [project] с метаданными.

С 2021 года это окончательный стандарт. setup.py остался в legacy, новые проекты пишут только pyproject.toml.

TOML
выбрали как формат, потому что он строго типизирован (не как YAML, где «yes» превращается в True), читаем человеком (не как JSON без комментариев) и однозначно парсится.

Анатомия файла

Минимальный pyproject.toml, который создаёт uv init:

[project]
name = "my-etl"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = []

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

Разберём секции.

[project] — метаданные пакета

Это сердце манифеста. Поля стандартизированы PEP 621:

  • name — имя пакета. То, под чем он будет на PyPI. Обязательное.
  • version — версия в формате
    SemVer
    , например 0.1.0. Обязательное.
  • description — одна строка про проект, попадает в PyPI.
  • readme — путь до README. PyPI отрендерит его на странице пакета.
  • requires-python — какие версии Python поддерживаются. >=3.13 значит «3.13 и новее».
  • dependencies — список
    runtime dependencies
    . Заполняется командой uv add.

Дальше менее обязательные:

  • authors / maintainers — список людей, кто пишет проект.
  • license — обычно { text = "MIT" } или { file = "LICENSE" }.
  • keywords — список ключевых слов для поиска на PyPI.
  • classifiers — формальные категории, например "Programming Language :: Python :: 3.13".

Для внутренних DE-скриптов, которые не публикуются в PyPI, большая часть полей формальна — заполняйте «как-нибудь», главное чтобы не падало.

[build-system] — кто строит пакет

PEP 517 описал, что любой Python-проект должен явно объявить инструмент сборки:

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

uv init ставит

hatchling
— это backend, который умеет собрать ваш пакет в
wheel
.

Другие варианты вы встретите в чужих проектах:

  • setuptools.build_meta — классика, до сих пор широко используется в legacy.
  • poetry-core — для проектов на poetry.
  • flit_core — для маленьких пакетов.
  • maturin — для пакетов с Rust-расширениями (например, pydantic-core).

Менять build-system руками обычно не нужно — то, что поставил uv init, работает в 99% случаев.

[tool.*] — настройки инструментов

Это самая «живая» часть. Сюда любой инструмент может писать свои настройки:

[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"

[tool.mypy]
strict = true
python_version = "3.13"

[tool.uv]
dev-dependencies = ["pytest>=8.0", "ruff>=0.5"]

Раньше каждый инструмент имел свой файл: .flake8, setup.cfg, pytest.ini, mypy.ini, pylintrc. Теперь всё в одном месте. Корневая папка проекта чище, на одной странице видно весь стек.

pyproject.toml по секциям

Один файл объединяет метаданные, build-систему и настройки всего инструментария.

[project]метаданные: name, version, depsPEP 621. Что попадёт в PyPI.
[build-system]hatchling/setuptools/...PEP 517. Чем собирать wheel.
[dependency-groups]dev, test, docs...PEP 735. Группы dev-зависимостей.
[tool.uv]источники, конфигурация uv
[tool.ruff]настройки линтера
[tool.pytest.ini_options]настройки тестов
[tool.mypy]настройки type-checker

Lockfile uv.lock

Когда вы пишете в pyproject.toml:

dependencies = ["requests>=2.30"]

— это диапазон. Какая именно версия установится — определяется в момент uv add или uv sync. Если коллега через месяц запустит uv sync, может оказаться, что вышла requests 2.33 и поставится она. Это иногда ломает воспроизводимость: у вас 2.30, у коллеги 2.33, у CI ещё что-то. Бизнес-логика молча работает иначе.

uv.lock решает проблему. Это файл, в котором записаны точные версии всех пакетов, включая транзитивные:

[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "certifi" },
    { name = "charset-normalizer" },
    { name = "idna" },
    { name = "urllib3" },
]

[[package]]
name = "urllib3"
version = "2.2.3"
...

Плюс контрольные суммы пакетов — uv sync сверит, что скачался ровно тот файл, что лежал у автора. Это и защита от воспроизводимости, и защита от

supply chain атак
.

Lockfile коммитится в git. Всегда. Без исключений. Это файл, который делает вашу команду «воспроизводимой». Если вы публикуете библиотеку на PyPI — uv.lock обычно не нужен пользователям (они сами выберут версии под свои проекты), но в монорепо приложения он обязателен.

WARNING

Не редактируйте uv.lock вручную. Никогда. Это файл-вывод инструмента — любое изменение перетрётся следующим uv sync или uv lock. Если нужно зафиксировать другую версию пакета — правьте pyproject.toml, потом перегенерируйте lockfile.

Dev-зависимости: две стратегии

Когда мы пишем uv add --dev pytest, нужно понять, куда именно uv это запишет. Существуют две конкурирующие конвенции.

optional-dependencies (старая, но рабочая)

[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5", "mypy>=1.10"]
docs = ["mkdocs", "mkdocs-material"]

«Опциональные» зависимости — это группы, которые ставятся явно:

pip install "my-etl[dev]"

Внутри pip install это часть стандарта (PEP 621), хорошо понимается всеми инструментами. Подходит, когда пакет публикуется в PyPI и пользователю может понадобиться [docs] или [dev].

dependency-groups (PEP 735, новый стандарт)

[dependency-groups]
dev = ["pytest>=8.0", "ruff>=0.5", "mypy>=1.10"]
docs = ["mkdocs"]

Принят в 2024 году. Идея: dev-зависимости — это не часть пакета. Они нужны разработчику, но не пользователю. Поэтому [project.optional-dependencies] для них концептуально неправильное место — там объявляются «фичи» пакета, а dev-зависимости — это инфраструктура проекта.

uv поддерживает оба варианта. По умолчанию uv add --dev пишет в новый dependency-groups. Команда uv sync ставит и runtime, и dev — по умолчанию. Чтобы поставить только runtime (как в production):

uv sync --no-dev

Это та команда, что обычно используется в production Docker-образах: ставится только то, что нужно для работы, без pytest и ruff.

Что выбрать

  • Для приложений (не публикуете на PyPI) — dependency-groups.
  • Для библиотек, которые публикуются на PyPI и могут пригодиться пользователям как «бесплатная» опция ([docs], [geospatial], [redis]) — optional-dependencies.

В обычном DE-проекте чаще нужен первый вариант.

Версионирование зависимостей

Когда вы пишете requests>=2.30, какие версии разрешены? А requests~=2.30? А requests==2.30.*? Это разные спецификаторы, и юниор постоянно их путает.

СпецификаторЗначениеПример: 2.30.0
==2.30.0Точно эта версия2.30.0 и никакая другая
==2.30.*Любая 2.30.x2.30.0, 2.30.5, но не 2.31
>=2.30Эта или выше2.30.0, 2.31, 3.0, 99.0
>=2.30,<3В диапазоне2.30.0, 2.99, но не 3.0
~=2.30Compatible release2.30.x, но не 2.31
~=2.30.0Compatible release patch-level2.30.x, но не 2.31.0
^2.30Caret (только в poetry)В стандарте PyPI/PEP не используется

Правило выбора в DE-проектах:

  • Прямые зависимости — обычно >=X.Y без верхней границы. Lockfile зафиксирует точную версию, а возможность обновления остаётся открытой.
  • Если знаете, что библиотека ломает совместимость в minor-версиях (редко, но бывает) — ограничьте сверху: >=2.5,<3.
  • Не пишите == в pyproject.toml, кроме исключительных случаев. Точные версии — это работа lockfile, не манифеста.
TIP

Когда вы добавляете пакет через uv add requests, по умолчанию uv записывает requests>=2.32.3 (последняя на момент добавления). Это разумно: lockfile зафиксирует точную версию, а в pyproject.toml остаётся читабельная нижняя граница.

Когда что-то идёт не так

Самые частые ошибки на старте.

Resolution conflictuv пишет «could not find a version of X compatible with Y». Это значит, что версии конфликтуют: один пакет требует numpy>=2.0, другой — numpy<2. Решения: либо обновить один из пакетов, либо явно зафиксировать совместимую версию numpy в dependencies.

Кэш протух — пакет в PyPI обновился, а uv берёт старую версию из кэша. Команда uv cache clean чистит локальный кэш.

Lockfile рассинхронизирован — кто-то поправил pyproject.toml руками, не запустил uv lock. Запустите uv lock --check в CI, чтобы ловить это автоматически.

Упражнение

Создайте новый проект:

mkdir deps-experiment
cd deps-experiment
uv init --python 3.13

Откройте pyproject.toml и вручную замените секцию dependencies на:

dependencies = [
    "requests>=2.30",
    "pydantic~=2.5",
    "python-dotenv==1.0.*",
]

Запустите:

uv lock

Откройте uv.lock и найдите версии трёх ваших пакетов. Заполните в README таблицу:

СпецификаторУстановилась версия
requests>=2.30?
pydantic~=2.5?
python-dotenv==1.0.*?

Критерии приёмки:

  • requests — любая >=2.30, обычно последняя стабильная (на 2026 год — 2.32.x или новее).
  • pydantic2.5.x, 2.6.x, …, но не 3.0.
  • python-dotenv1.0.x, но не 1.1.

В следующем уроке мы займёмся ruff — линтером и форматтером, который ваш pyproject.toml будет читать чаще всех остальных инструментов.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5