Первый Python-проект: uv, зависимости и команды

От пустой папки до проекта, который собирается одинаково на любой машине: uv init, закреплённая версия Python, lock-файл, первые зависимости и свои команды вида uv run lint.

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1
Зачем это

Первое, что ломается в чужом проекте, — это окружение. «У меня работает» не является аргументом, если у соседа не собирается. Прежде чем написать первую строку кода, мы делаем так, чтобы проект одинаково поднимался на любой машине.

Что должно быть готово до начала: рабочее место из предыдущего списка — VS Code, git, Docker и uv установлены, папка проекта создана и открыта в редакторе.

Документация, которая пригодится: доки uv · проекты в uv · зависимости · спецификация pyproject.toml

Создание проекта

1
Проверить, что uv на месте

Выполняй во встроенном терминале VS Code, находясь в папке проекта. Если команда не найдена — вернись к списку про рабочее место.

Why: Дальше всё идёт через uv. Проверить его сейчас — десять секунд, выяснять потом посреди непонятной ошибки — полчаса.
$uv --version
Check
  • Команда напечатала версию
  • Терминал открыт в папке проекта, а не где-то ещё
2
Создать проект с флагом --package

Точка в конце означает «в текущей папке».

Флаг --package обязателен. Без него uv создаст простой скриптовый проект без раздела [build-system], и свои команды (шаги ниже) просто не заработают — uv run lint ответит Failed to spawn: lint / program not found.

Посмотри, что появилось: pyproject.toml, README.md, .python-version, .gitignore и папка src/ с пакетом.

Why: uv умеет два вида проектов: просто набор скриптов и настоящий устанавливаемый пакет. Нам нужен второй: только у него есть точки входа, из которых получаются команды проекта.
$uv init --package .
Check
  • В папке появился pyproject.toml
  • В нём есть раздел [build-system] — это главный признак, что флаг сработал
  • Появилась папка src с пакетом внутри
3
Закрепить версию Python

После команды открой .python-version — там одна строка с версией.

Заодно поправь в pyproject.toml строку requires-python — uv мог поставить туда более старую версию:

toml
1requires-python = ">=3.12"
Why: Без закреплённой версии проект соберётся под тем Python, который оказался на машине. Поведение между версиями отличается, и отлаживать такое тяжело: у тебя работает, у другого нет, код один и тот же.
$uv python pin 3.12
Check
  • Файл .python-version содержит 3.12
  • В pyproject.toml requires-python тоже >=3.12

Структура

4
Переименовать пакет в app

uv создал src/<имя_проекта>/. Нам нужен пакет app/ в корне.

Перенеси папку и удали пустую src, а затем скажи сборщику, где искать код — добавь в pyproject.toml перед разделом [build-system]:

toml
1[tool.hatch.build.targets.wheel]
2packages = ["app", "scripts"]

Папки scripts ещё нет — создадим её через несколько шагов, поэтому вписываем сразу обе.

Why: Сборщик по умолчанию ищет пакет с именем проекта в `src/`. Как только ты переименовал папку, ему надо сказать об этом явно — иначе установка проекта упадёт с сообщением, что пакет не найден.
Check
  • Папка app лежит в корне проекта
  • Папки src больше нет
  • В pyproject.toml есть раздел [tool.hatch.build.targets.wheel]

Зависимости

5
Добавить первые зависимости

После команды открой pyproject.toml — в разделе dependencies появились две строки:

toml
1dependencies = [
2 "fastapi>=0.115.0",
3 "uvicorn>=0.30.0",
4]

Заодно появился uv.lock и папка .venv. Загляни в uv.lock — он намного больше двух строк. Пойми, почему.

Why: В pyproject ты пишешь, что нужно проекту. В uv.lock uv записывает точные версии **всего дерева** — включая зависимости твоих зависимостей. Именно lock-файл делает сборку воспроизводимой.
$uv add fastapi uvicorn
Check
  • В pyproject.toml в dependencies появились fastapi и uvicorn
  • Создан файл uv.lock
  • Ты можешь объяснить, почему uv.lock гораздо больше списка в pyproject
6
Запустить код в окружении проекта

Первая победа: пакет установлен и импортируется.

Попробуй ради эксперимента ту же строку без uv run — просто python -c "import fastapi". Скорее всего упадёт с ModuleNotFoundError, и это правильно.

Why: `uv run` запускает команду **внутри окружения проекта**. Голый `python` берёт тот интерпретатор, что первым попался в PATH, и про твои зависимости ничего не знает. Почувствовать эту разницу сейчас — значит не ловить её потом часами.
$uv run python -c "import fastapi; print(fastapi.__version__)"
Check
  • Команда напечатала версию fastapi
  • Ты попробовал то же без uv run и видел разницу
7
Добавить инструмент разработчика

Флаг --dev кладёт зависимость в отдельный раздел, а не в основной:

toml
1[dependency-groups]
2dev = [
3 "ruff>=0.16.0",
4]

Сравни с тем, куда попали fastapi и uvicorn. Доки: ruff

Why: На проде нужен только код, который работает. Линтер, тесты и форматтер туда ехать не должны: они раздувают образ и добавляют то, что можно атаковать. Разделение заводится сразу, потом разгребать дороже.
$uv add --dev ruff
Check
  • ruff оказался в разделе dev, а не в dependencies
  • Ты можешь объяснить, почему это разные списки

Команды проекта

8
Написать первую команду

Создай папку scripts/ с пустым __init__.py и файлом commands.py:

python
1"""Команды проекта. Объявляются в pyproject.toml, раздел [project.scripts]."""
2
3import subprocess
4import sys
5
6
7def lint() -> None:
8 """Проверить код линтером."""
9 result = subprocess.run(["ruff", "check", "."], check=False)
10 sys.exit(result.returncode)

Обрати внимание на sys.exit(...): код возврата пробрасывается наружу.

Why: Без `sys.exit` команда всегда завершается успешно, даже когда линтер нашёл ошибки. Потом это выстрелит в CI: сборка зелёная, проверки красные, и никто не замечает.
Check
  • Файл scripts/commands.py создан
  • В папке scripts есть __init__.py
  • Функция возвращает код через sys.exit
9
Объявить команду в pyproject.toml

Добавь раздел (если uv уже создал его с примером — замени содержимое):

toml
1[project.scripts]
2lint = "scripts.commands:lint"

Слева от знака равенства — имя команды, справа — путь к функции в формате модуль:функция.

Why: Смысл не в экономии символов, а в общем словаре: команда одна и та же на любой машине, её не надо помнить и негде ошибиться. Заодно это документация: по списку команд видно, что с проектом вообще делают.
Check
  • В pyproject.toml есть раздел [project.scripts] с командой lint
  • Путь указан через двоеточие: модуль:функция
10
Запустить свою команду

При первом запуске uv доустановит сам проект в окружение — увидишь строку вида + имя-проекта==0.1.0 (from file:///...). Это нормально и именно так рождаются команды.

Ожидаемый результат — All checks passed!

Если пишет Failed to spawn: lint — значит нет раздела [build-system] или сборщик не видит папку scripts. Вернись к шагам про --package и [tool.hatch.build.targets.wheel].

Why: Здесь сходится всё предыдущее: проект устанавливаемый, сборщик знает про пакеты, точка входа объявлена. Любое звено мимо — и команды нет.
$uv run lint
Check
  • Команда отработала и напечатала результат проверки
  • В выводе было видно, как устанавливается сам проект
11
Проверить, что ничего не затенено

Команда печатает путь к тому файлу, который реально импортируется. Должен быть scripts/commands.py.

Запомни эту команду — она пригодится каждый раз, когда «код точно правильный, а ведёт себя странно».

Why: Если рядом окажутся файл `commands.py` и папка `commands/`, Python выберет папку, а файл не импортируется никогда. Он будет выглядеть рабочим кодом, но будет мёртв. Ничего не падает — просто вызывается не тот код, который ты читаешь.
$uv run python -c "import importlib.util as u; print(u.find_spec('scripts.commands').origin)"
Check
  • Напечатан путь к scripts/commands.py
  • В папке scripts нет одновременно файла и папки с одинаковым именем

Git

12
Разобраться, что идёт в репозиторий

uv уже создал .gitignore. Проверь, что в нём есть .venv, и что там нет uv.lock.

Минимум:

gitignore
1.venv/
2__pycache__/
3*.py[cod]
4.env

Идёт в git: pyproject.toml, uv.lock, .python-version, код. Не идёт: .venv, __pycache__, .env.

Why: `.venv` — это собранные пакеты под конкретную ОС: у соседа они не заработают, а репозиторий раздуют. `uv.lock` — наоборот, текстовое описание того, какие версии нужны. Без него теряется весь смысл воспроизводимости.
Check
  • .venv перечислен в .gitignore
  • uv.lock В .gitignore не перечислен
  • .env перечислен, даже если файла ещё нет
13
Проверить состав будущего коммита

В списке должны быть pyproject.toml, uv.lock, .python-version, .gitignore, папки app и scripts.

Папки .venv в списке быть не должно. Если она там есть — ты успел добавить её в git до того, как появился .gitignore. Лечится: git rm -r --cached .venv

Why: Проверять состав коммита до коммита — привычка, которая однажды спасёт от утечки секрета. Файл, попавший в историю, удалить оттуда почти невозможно.
$git status
Check
  • .venv не попадает в список
  • uv.lock и .python-version в списке есть
Как это в бою

В боевом проекте, на который мы ориентируемся, — 978 файлов .py, и всё окружение поднимается одной командой. В корне лежат ровно те же три файла, что ты только что сделал.

Команд там четырнадцать: dev, serve, worker, test, lint, format, migrate, deploy и другие. Новый человек не выясняет, чем запускать проект, — он смотрит список команд.

И что там вышло неудачно. Рядом лежат файл scripts/commands.py и пакет scripts/commands/ — с одинаковым именем. Python выбирает пакет, поэтому файл не импортируется никогда. Мёртвый код, который выглядит рабочим и вводит в заблуждение при чтении.

Второе: команда bootstrap описана как «полная инициализация проекта», а вызывает функцию activate — то есть делает не то, что заявлено.

Мы такого не повторяем: имя пакета и имя модуля в одной папке не совпадают, а команда делает ровно то, что написано в её описании. Поэтому шаг с find_spec выше — не формальность.

Задание

Добавь вторую команду — format, которая запускает ruff format . и точно так же пробрасывает код возврата.

Обрати внимание: назвать функцию просто format нельзя — так уже называется встроенная функция Python, и ты её затенишь. Назови format_code, а имя команды оставь format:

toml
1[project.scripts]
2lint = "scripts.commands:lint"
3format = "scripts.commands:format_code"

Критерий готовности: uv run lint и uv run format работают и делают разное. Создай файл с кривыми отступами и убедись, что первая ругается, а вторая правит.

Почему uv.lock идёт в git, а .venv — нет?
Ты поставил пакет через pip install, а при следующем запуске его нет. Почему?
В папке лежат commands.py и папка commands/. Что импортируется при import commands?
Зачем команде проекта пробрасывать код возврата через sys.exit?
Готово

У тебя проект, который восстанавливается одной командой на любой машине, с закреплённой версией Python и со своими командами.

Кода пока нет — и это нормально. Сначала строится то, во что код будет ложиться.

Что дальше: структура проекта — пять слоёв внутри app/ и правило, по которому они зависят друг от друга. После этого — настройки, точка входа и первый эндпоинт.