Где живёт описание проекта
В прошлом уроке мы разобрали uv как инструмент. Теперь — про файл, в который uv всё записывает: pyproject.toml. Это
.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 518 — ввёл сам файл
pyproject.tomlкак стандарт для конфигурации сборки. - PEP 517 — описал, как build-системы (setuptools, hatchling, poetry-core) подключаются через этот файл.
- PEP 621 (2020) — стандартизировал секцию
[project]с метаданными.
С 2021 года это окончательный стандарт. setup.py остался в legacy, новые проекты пишут только pyproject.toml.
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 ставит
Другие варианты вы встретите в чужих проектах:
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. Теперь всё в одном месте. Корневая папка проекта чище, на одной странице видно весь стек.
Один файл объединяет метаданные, build-систему и настройки всего инструментария.
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 сверит, что скачался ровно тот файл, что лежал у автора. Это и защита от воспроизводимости, и защита от
Lockfile коммитится в git. Всегда. Без исключений. Это файл, который делает вашу команду «воспроизводимой». Если вы публикуете библиотеку на PyPI — uv.lock обычно не нужен пользователям (они сами выберут версии под свои проекты), но в монорепо приложения он обязателен.
Не редактируйте 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.x | 2.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.30 | Compatible release | 2.30.x, но не 2.31 |
~=2.30.0 | Compatible release patch-level | 2.30.x, но не 2.31.0 |
^2.30 | Caret (только в poetry) | В стандарте PyPI/PEP не используется |
Правило выбора в DE-проектах:
- Прямые зависимости — обычно
>=X.Yбез верхней границы. Lockfile зафиксирует точную версию, а возможность обновления остаётся открытой. - Если знаете, что библиотека ломает совместимость в minor-версиях (редко, но бывает) — ограничьте сверху:
>=2.5,<3. - Не пишите
==вpyproject.toml, кроме исключительных случаев. Точные версии — это работа lockfile, не манифеста.
Когда вы добавляете пакет через uv add requests, по умолчанию uv записывает requests>=2.32.3 (последняя на момент добавления). Это разумно: lockfile зафиксирует точную версию, а в pyproject.toml остаётся читабельная нижняя граница.
Когда что-то идёт не так
Самые частые ошибки на старте.
Resolution conflict — uv пишет «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или новее).pydantic—2.5.x,2.6.x, …, но не3.0.python-dotenv—1.0.x, но не1.1.
В следующем уроке мы займёмся ruff — линтером и форматтером, который ваш pyproject.toml будет читать чаще всех остальных инструментов.