Лекция 1. Сборка пакетов и инструменты#
0. О курсе#
Команда#
Лектор — Иван Лущ.
Группа |
1 |
2 |
3 |
4 |
1 (ЭАД) |
|---|---|---|---|---|---|
Семинарист |
Программа#
Курс идёт от языка к системе — 13 лекций:
Блок I. Фундамент + инженерия — сборка пакетов, типы, тесты, отладка (1-4).
Блок II. Конкурентность —
threading,multiprocessing,asyncio(5-7).Блок III. Метапрограммирование — дескрипторы, метаклассы (8-9).
Блок IV. Внутренности CPython — JIT, память и сборка мусора, профилирование (10-12).
Блок V. Наружу — расширения на C и привязки к чужим библиотекам (13).
Материалы и связь#
Задачи, таблица результатов и дедлайны — manytask (поверх SourceCraft, регистрация через Яндекс ID; при регистрации создаётся ваш приватный форк — туда пушите).
Оценка#
Итог = 0.2 * семинары + 0.5 * задачи + 0.3 * мок-интервью
Семинары — активность на семинарах.
Задачи — регулярные ДЗ через manytask, с автопроверкой в CI. Защита решений. Преподаватель вправе в любой момент семестра вызвать студента на устную защиту любой сданной задачи или группы задач. Студент обязан объяснить свое решение, работу кода и используемые языковые конструкции. Если студент не может дать связного объяснения по существу, баллы за всю соответствующую группу задач в manytask обнуляются; повторная сдача этих задач не восстанавливает балл. Если провалено больше одной защиты, преподаватель вправе написать дисциплинарную записку за академические нарушения студента.
Мок-интервью — устная форма контроля в формате технического Python-интервью. Проходит на последних 2-3 лекциях семестра (декабрь). Часть I: 4-5 открытых теоретических вопросов равномерно по блокам курса («почему
list[Dog]нельзя передать вместоlist[Animal], аSequence[Dog]— можно?», «чемlogger.error(exc)хужеlogger.exception()?»). Часть II: разбор нескольких задач из сданных — как устроено решение, почему выбрал такой подход, какие компромиссы. Каждая часть до 5 баллов, итого 0-10.
План#
sdistиwheel— два формата, в которых пакет уезжает на PyPI.pyproject.toml— бэкенды и фронтенды, метаданные, зависимости.Сборка — что лежит внутри артефактов и как работает протокол PEP 517.
PyPI — откуда приезжает пакет и как не поставить чужой.
Установка — виртуальные окружения,
uv, режим редактирования, lock-файлы.Статический анализ —
ruffиmypy --strictс первого дня.
1. Что мы собираем: sdist и wheel#
Два формата распространения. Оба живут на PyPI, оба знает pip install.
sdist (source distribution, .tar.gz): исходники + pyproject.toml.
На машине из sdist wheel собирается локально (если есть код на
C — понадобится компилятор).
wheel (built distribution, .whl): готовые файлы, pip install
их просто распаковывает в site-packages/. Никакой сборки на клиенте.
Имя wheel — {name}-{version}-{python-tag}-{abi-tag}-{platform-tag}.whl:
greet-0.1.0-py3-none-any.whl— чистый Python (pure Python), без расширений на C: работает где угодно.numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl— только CPython 3.14, только его ABI (application binary interface — то, как скомпилированный код общается с интерпретатором), только macOS 14+ на arm64.
Логика pip install foo:
Ищет wheel под твою платформу, качает и распаковывает.
Если wheel нет, качает sdist, собирает wheel локально, потом ставит.
pip install .делает то же самое, только берёт исходники из локального каталога.
Значит наш pyproject.toml должен описать пакет так, чтобы pip и uv
знали, как собрать оба формата.
2. pyproject.toml#
Раньше в корне жил зоопарк: setup.py, setup.cfg, MANIFEST.in,
requirements.txt, requirements-dev.txt, .flake8, mypy.ini,
pytest.ini, .coveragerc, tox.ini. Всё это переехало в один
pyproject.toml через три PEP:
PEP 518 (2016) —
[build-system]: чем и как собирать.PEP 517 (2015-2017) — интерфейс фронтенда (
pip) с бэкендом (setuptools,hatchling, …).PEP 621 (2020) —
[project]: стандартные метаданные, одинаковые для всех бэкендов.
Когда стало работать
Файл pyproject.toml существует с 2016 (PEP 518). Но полноценным, с
секцией [project], он стал только в 2022 (setuptools 61 + pip 22).
Раньше проекты жили с гибридом pyproject.toml + setup.py.
Живой пример — lectures/01-packaging/demo-project/:
!cat demo-project/pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "greet"
version = "0.1.0"
description = "Тривиальный пакет для демонстрации packaging"
readme = "README.md"
requires-python = ">=3.14"
authors = [{ name = "Иван Лущ", email = "ch0p1k3@yandex.ru" }]
license = "MIT"
dependencies = []
[project.optional-dependencies]
test = ["pytest>=8"]
lint = ["ruff>=0.8", "mypy>=1.13"]
[project.scripts]
greet = "greet.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/greet"]
[tool.hatch.build.targets.sdist]
# dist* — иначе в sdist попадают dist-a/ и dist-b/, собранные лекцией раньше.
exclude = [".venv*", "dist*", "__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache"]
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM"]
[tool.mypy]
python_version = "3.14"
strict = true
Разберём по секциям.
[build-system] — чем собирать#
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
requires— что нужно в момент сборки (не в рантайме!). Обычно сам бэкенд, иногда ещёcythonилиcmakeдля расширений на C.build-backend— модуль, который вызывает фронтенд. Экспортирует функцииbuild_sdist,build_wheel,build_editable.
Фронтенд (pip, uv, python -m build) не знает, как собирать твой
код. Он делегирует бэкенду через стандартный API PEP 517:
pip install .
-> читает [build-system] из pyproject.toml
-> ставит `requires` в изолированное окружение сборки
-> import build-backend, вызывает build_wheel()
-> получает .whl, ставит в целевой venv
Изолированное окружение сборки
pip создаёт временный каталог pip-build-env-* в $TMPDIR (на Linux
это /tmp, на macOS — /var/folders/…/T), ставит туда requires и
оттуда зовёт бэкенд. Это даже не полноценный venv, а просто каталог,
который на время сборки подкладывается в sys.path; потом удаляется.
Изоляция нужна, чтобы твоя старая версия setuptools не сломала сборку
зависимости, которая требует свежую.
Бэкендов много, зачем?#
Три причины. Первая: PEP 517/518 стандартизировали интерфейс между
фронтендом и бэкендом, и появилась возможность писать альтернативы
setuptools (до этого он был единственным). Вторая: разным типам
кода нужны разные инструменты. Третья: некоторые бэкенды идут в связке
со своей консольной командой (CLI, command-line interface) — poetry,
hatch, pdm — и претендуют на весь цикл работы с пакетом.
Дерево выбора:
Чистый Python —
hatchling(по умолчанию в 2026). Минималистично —flit_core.Простые расширения на C/C++ —
setuptools(устаревший, но примеров много).Сложные расширения с CMake —
scikit-build-core.Rust через PyO3 —
maturin(единственный разумный выбор).Уже в экосистеме poetry/pdm — их родные бэкенды.
Мы используем hatchling — чистый Python, PEP 621 из коробки, без своих частных секций.
Подробнее про менеджер vs бэкенд — ниже; пока просто запомни:
hatchling.build — это бэкенд (мы им пользуемся), а hatch —
отдельный CLI-инструмент с похожим названием.
Фронтендов тоже несколько#
Фронтенд — тот, кто вызывает бэкенд через API PEP 517. Два подвида.
Фронтенд-установщик — ставит пакет в venv (фронтенд зовёт бэкенд,
получает wheel, распаковывает в site-packages/):
pip install— классика. Самpipпри этом не часть стандартной библиотеки: это отдельный проект со своими релизами, который просто едет в комплекте с CPython — готовым.whl-файлом (PEP 453). Приpython -m venvэтот файл распаковывается в новое окружение, отсюда и ощущение, чтоpipесть всегда.uv venvего не распаковывает — тамpipне найдётся.uv pip install/uv add— тот же интерфейс, что уpip, но заметно быстрее на большом дереве зависимостей.pipx install— для самостоятельных CLI-утилит, каждая в свой изолированный venv.poetry install/pdm install/hatch env create— в рамках своих экосистем.
Фронтенд-сборщик — только собирает dist/*.tar.gz и dist/*.whl, не
ставит:
python -m build— минимальный сборщик от PyPA, группы, которая и пишет стандарты упаковки. Умеет ровно одно: собратьsdistиwheel.uv build— то же, входит в комплектuv.hatch build/poetry build/pdm build— в рамках их экосистем.
Живая проверка, что все фронтенды-сборщики равнозначны (собирают тот же
wheel из одного pyproject.toml):
%%bash
# 1. Сборщик от PyPA — тянем `build` через `uv run --with`
# (ставится только на время вызова, ничего не оседает в venv).
rm -rf demo-project/dist-a
uv run --with build --no-project -- \
python -m build --outdir demo-project/dist-a demo-project 2>&1 | tail -3
ls demo-project/dist-a/
- hatchling==1.33.0
* Building wheel...
Successfully built greet-0.1.0.tar.gz
and greet-0.1.0-py3-none-any.whl
greet-0.1.0-py3-none-any.whl
greet-0.1.0.tar.gz
%%bash
# 2. uv build — то же самое
rm -rf demo-project/dist-b
uv build --out-dir demo-project/dist-b demo-project 2>&1 | tail -2
ls demo-project/dist-b/
Successfully built demo-project/dist-b/greet-0.1.0.tar.gz
Successfully built
demo-project/dist-b/greet-0.1.0-py3-none-any.whl
greet-0.1.0-py3-none-any.whl
greet-0.1.0.tar.gz
Проверим — считаем sha256 обоих артефактов:
import hashlib
from pathlib import Path
for name in ("greet-0.1.0-py3-none-any.whl", "greet-0.1.0.tar.gz"):
a = (Path("demo-project/dist-a") / name).read_bytes()
b = (Path("demo-project/dist-b") / name).read_bytes()
print(f"{name}")
print(f" sha256(a) = {hashlib.sha256(a).hexdigest()[:24]}...")
print(f" sha256(b) = {hashlib.sha256(b).hexdigest()[:24]}...")
print(f" identical: {a == b}")
print()
greet-0.1.0-py3-none-any.whl
sha256(a) = 0ee48c18c16f085ab3ea3614...
sha256(b) = 0ee48c18c16f085ab3ea3614...
identical: True
greet-0.1.0.tar.gz
sha256(a) = 3987ecf0fc605b0e4b7fe55d...
sha256(b) = 3987ecf0fc605b0e4b7fe55d...
identical: True
Оба артефакта совпали побайтово. Это не случайность: hatchling фиксирует
все временные метки на дату 2020-02-02 — и у записей внутри zip-архива
wheel, и у файлов внутри tar, и даже в поле mtime заголовка gzip.
Смотрим руками:
import struct, zipfile
from pathlib import Path
with zipfile.ZipFile("demo-project/dist-a/greet-0.1.0-py3-none-any.whl") as z:
print("записи wheel:", {i.date_time for i in z.infolist()})
head = Path("demo-project/dist-a/greet-0.1.0.tar.gz").read_bytes()[:10]
print("заголовок gzip:", head.hex())
print("mtime в нём :", struct.unpack("<I", head[4:8])[0])
записи wheel: {(2020, 2, 2, 0, 0, 0)}
заголовок gzip: 1f8b08000011365e02ff
mtime в нём : 1580601600
Это стандарт Reproducible Builds:
один и тот же исходник на любой машине даёт идентичный артефакт, значит
по хешу можно проверить, что никто не подменил пакет по дороге. Дату
переопределяет переменная SOURCE_DATE_EPOCH.
Если хеши вдруг разъехались — в артефакт попало лишнее. Типичная причина
для sdist: в него замело dist-a/ и dist-b/ от прошлых сборок, потому
что они не попали в [tool.hatch.build.targets.sdist] exclude.
Итог: фронтенды взаимозаменяемы, они говорят с одним и тем же
hatchling.build через API PEP 517. За воспроизводимость отвечает
бэкенд, а не фронтенд.
Полный набор слоёв
Работа с пакетом разбита на слои. Каждый — независимая ось выбора:
Менеджер:
pip+venv/uv/poetry/hatch/pdm— что всем оркестрирует.Фронтенд-сборщик:
python -m build/uv build/<менеджер> build— что вызывает бэкенд.Бэкенд:
hatchling/setuptools/flit_core/maturin— что собирает.Фронтенд-установщик:
pip install/uv pip install— что кладёт wheel в venv.
Комбинации свободные. У нас: бэкенд hatchling + менеджер uv (он же
ставит, он же собирает).
[project] — метаданные PEP 621#
!sed -n '/^\[project\]/,/^\[project\.scripts\]/p' demo-project/pyproject.toml | sed '$d'
[project]
name = "greet"
version = "0.1.0"
description = "Тривиальный пакет для демонстрации packaging"
readme = "README.md"
requires-python = ">=3.14"
authors = [{ name = "Иван Лущ", email = "ch0p1k3@yandex.ru" }]
license = "MIT"
dependencies = []
[project.optional-dependencies]
test = ["pytest>=8"]
lint = ["ruff>=0.8", "mypy>=1.13"]
Полный набор полей:
[project]
name = "greet" # имя на PyPI
version = "0.1.0" # схема версий — PEP 440
description = "..." # для карточки на PyPI
readme = "README.md"
requires-python = ">=3.14"
license = "MIT" # короткое имя лицензии, PEP 639
license-files = ["LICENSE"] # файлы едут в dist-info/licenses/
authors = [{ name = "...", email = "..." }]
classifiers = ["Programming Language :: Python :: 3.14"]
dependencies = ["requests>=2.31", "click~=8.1"]
[project.urls]
Homepage = "https://example.com"
Repository = "https://github.com/user/greet"
Схему версий задаёт PEP 440, и она
шире привычной тройки мажор.минор.патч: 1.0a1 (альфа), 1.0rc2
(кандидат в релиз), 1.0.post1 (переиздание того же кода), 1.0.dev3
(ещё не релиз). Версию можно не писать руками, а вычислять при сборке:
dynamic = ["version"] плюс настройка бэкенда.
PEP 508: синтаксис зависимостей#
requests>=2.31,<3.0
click~=8.1 # совместимый выпуск == >=8.1,<9.0
numpy>=1.26; python_version >= "3.14" # маркер окружения
urllib3[socks]>=2.0 # extras
~= растит только последний компонент: ~=8.1 это >=8.1,<9.0, а
~=1.2.3 это >=1.2.3,<1.3.0. Маркеры окружения: os_name,
sys_platform, python_version, implementation_name.
Дополнительные зависимости (extras)#
[project.optional-dependencies]
test = ["pytest>=8", "hypothesis>=6.100"]
lint = ["ruff>=0.8", "mypy>=1.13"]
dev = ["greet[test,lint]", "ipython"] # ссылка на себя
Ставится так: uv pip install '.[test,lint]'. Кавычки обязательны —
в zsh (по умолчанию на macOS) [...] это шаблон имени файла, без
кавычек шелл упадёт с no matches found.
Точки входа CLI#
[project.scripts]
greet = "greet.cli:main"
После установки в venv появляется исполняемый greet в bin/:
%%bash
rm -rf /tmp/greet-cli
uv venv -q /tmp/greet-cli
uv pip install -q --python /tmp/greet-cli/bin/python ./demo-project
/tmp/greet-cli/bin/greet Ivan
Hello, Ivan!
Что внутри bin/greet:
!head -5 /tmp/greet-cli/bin/greet
#!/private/tmp/greet-cli/bin/python
# -*- coding: utf-8 -*-
import sys
from greet.cli import main
if __name__ == "__main__":
[tool.*] — конфиг инструментов#
Конвенция PEP 518: любой инструмент читает свою секцию [tool.<name>].
У нас в demo-project:
!grep -A2 '^\[tool' demo-project/pyproject.toml
[tool.hatch.build.targets.wheel]
packages = ["src/greet"]
[tool.hatch.build.targets.sdist]
# dist* — иначе в sdist попадают dist-a/ и dist-b/, собранные лекцией раньше.
exclude = [".venv*", "dist*", "__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache"]
--
[tool.ruff]
line-length = 100
target-version = "py314"
--
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM"]
[tool.mypy]
python_version = "3.14"
strict = true
Пакет с иерархией: demo-ecommerce#
greet — это плоский пакет из одного модуля. Реально код чаще
выглядит как иерархия: пакеты с подпакетами, __init__.py,
относительные импорты.
В lectures/01-packaging/demo-ecommerce/ — тот самый ecommerce из
базового курса Даниэля,
но с добавленным pyproject.toml:
%%bash
find demo-ecommerce -type f | sort | grep -v __pycache__ | grep -v dist
demo-ecommerce/main.py
demo-ecommerce/pyproject.toml
demo-ecommerce/README.md
demo-ecommerce/src/eco
mmerce/__init__.py
demo-ecommerce/src/ecommerce/payments/__init__.py
demo-ecommerce/src/ecommerce/pa
yments/paypal.py
demo-ecommerce/src/ecommerce/payments/stripe.py
demo-ecommerce/src/ecommerce/paymen
ts/utils.py
demo-ecommerce/src/ecommerce/products.py
demo-ecommerce/src/ecommerce/utils.py
Тот же код, который вы видели у Даниэля: ecommerce/products.py с
абсолютными импортами, ecommerce/payments/stripe.py с относительными,
from .paypal import * в __init__.py.
Наша задача — сделать этот код устанавливаемым. Всё, что нужно, —
один файл pyproject.toml:
!cat demo-ecommerce/pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "ecommerce"
version = "0.1.0"
description = "Демо иерархического пакета из базового курса Даниэля, оформленный как installable"
readme = "README.md"
requires-python = ">=3.14"
authors = [{ name = "Daniel Shinoda" }, { name = "Ivan Lushch" }]
license = "MIT"
dependencies = []
[tool.hatch.build.targets.wheel]
packages = ["src/ecommerce"]
[tool.hatch.build.targets.sdist]
exclude = [".venv*", "dist", "__pycache__"]
Ключевое отличие от demo-project/greet:
[tool.hatch.build.targets.wheel]
packages = ["src/ecommerce"] # где лежит пакет — src-layout
Ставим и запускаем:
%%bash
rm -rf /tmp/venv-ecom
uv venv -q /tmp/venv-ecom
uv pip install -q --python /tmp/venv-ecom/bin/python ./demo-ecommerce
/tmp/venv-ecom/bin/python demo-ecommerce/main.py
Загружается модуль `ecommerce`. Родительский пакет `ecommerce`
З
агружается модуль `ecommerce.products`. Родительский пакет `ecommerc
e`
Загружается модуль `ecommerce.payments`. Родительский пакет `ec
ommerce.payments`
Загружается модуль `ecommerce.payments.paypal`. Родитель
ский пакет `ecommerce.payments`
Загружается модуль `ecommerce.payments.stri
pe`. Родительский пакет `ecommerce.payments`
Загрузился модуль `eco
mmerce.payments`. Родительский пакет `ecommerce.payments`
paypal_func called
paymen
ts utils_func called
ecommerce utils_func called
paypal
stripe_func called
payments utils_func calle
d
ecommerce utils_func called
stripe
Загрузился модуль `ecommerce`. Родитель
ский пакет `ecommerce`
Обрати внимание на порядок вывода — видно порядок загрузки модулей:
ecommerce → его __init__.py → импортит ecommerce.products →
тот тянет ecommerce.payments → тянет paypal и stripe (через *)
→ каждый тянет utils (свой и родительский).
import = выполнение кода.
Почему __init__.py не должен делать много
Каждый import тянет весь этот граф. Если в __init__.py тяжёлая
инициализация (открытие БД, чтение файлов, сетевые запросы) — она
произойдёт при первом импорте, часто в неожиданный момент. Держи
__init__.py чистым: только реэкспорты и __all__.
Смотрим что попало в wheel:
%%bash
cd demo-ecommerce
rm -rf dist/
uv build 2>&1 | tail -3
Building wheel from source distribution...
Successfully built dist/ecommerce-0.1.0.
tar.gz
Successfully built dist/ecommerce-0.1.0-py3-none-any.whl
import zipfile
with zipfile.ZipFile("demo-ecommerce/dist/ecommerce-0.1.0-py3-none-any.whl") as z:
for name in z.namelist():
print(name)
ecommerce/__init__.py
ecommerce/products.py
ecommerce/utils.py
ecommerce/payments/__init__.py
ecommerce/payments/paypal.py
ecommerce/payments/stripe.py
ecommerce/payments/utils.py
ecommerce-0.1.0.dist-info/METADATA
ecommerce-0.1.0.dist-info/WHEEL
ecommerce-0.1.0.dist-info/RECORD
Все .py файлы из src/ecommerce/ попали в wheel как ecommerce/.
Иерархия сохранена, main.py внутрь не попал — это скрипт для
запуска демо, а не часть пакета.
3. Сборка через бэкенд — потрошим wheel#
%%bash
cd demo-project
rm -rf dist/
uv build 2>&1 | tail -5
ls -la dist/
Building source distribution...
Building wheel from source distribution...
Successfu
lly built dist/greet-0.1.0.tar.gz
Successfully built dist/greet-0.1.0-py3
-none-any.whl
total 64
drwxr-xr-x 5 chopik staff 160 Nov 8 20:55 .
drwxr-xr-x 11 chopik staff
352 Nov 8 20:55 ..
-rw-r--r-- 1 chopik staff 1 Nov 8 20:55 .gitignore
-rw-r-
-r-- 1 chopik staff 1818 Nov 8 20:55 greet-0.1.0-py3-none-any.whl
-rw-r--r-- 1 chopik staff
20923 Nov 8 20:55 greet-0.1.0.tar.gz
Wheel — это ZIP. Смотрим через zipfile из стандартной библиотеки,
никакого unzip:
import zipfile, glob
wheel_path = glob.glob("demo-project/dist/*.whl")[0]
with zipfile.ZipFile(wheel_path) as z:
for name in z.namelist():
print(name)
greet/__init__.py
greet/cli.py
greet-0.1.0.dist-info/METADATA
greet-0.1.0.dist-info/WHEEL
greet-0.1.0.dist-info/entry_points.txt
greet-0.1.0.dist-info/RECORD
Что за файлы в dist-info/:
METADATA — плоский формат
[project](RFC 5322, как заголовки письма).WHEEL — версия формата плюс теги платформы.
RECORD — CSV
path,hash,size, по нему проверяется целостность при удалении.entry_points.txt — если есть
[project.scripts].
METADATA изнутри:
with zipfile.ZipFile(wheel_path) as z:
metadata_name = next(n for n in z.namelist() if n.endswith("METADATA"))
print(z.read(metadata_name).decode())
Metadata-Version: 2.5
Name: greet
Version: 0.1.0
Summary: Тривиальный пакет для демонстрации packaging
Author-email: Иван Лущ <ch0p1k3@yandex.ru>
License-Expression: MIT
Requires-Python: >=3.14
Provides-Extra: lint
Requires-Dist: mypy>=1.13; extra == 'lint'
Requires-Dist: ruff>=0.8; extra == 'lint'
Provides-Extra: test
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown
# greet — demo-пакет для лекции 1
Тривиальный пакет для демонстрации PEP 517/518/621, sdist vs wheel,
extras, ruff, mypy strict.
## Локально
```bash
uv sync --extra test --extra lint
uv run pytest
uv run ruff check .
uv run mypy --strict src/
```
Ровно это pip читает, когда ставит wheel: статический текст, никакого
кода не запускается. Более того, при разрешении зависимостей METADATA
качается с индекса отдельным файлом, не скачивая весь wheel целиком
(PEP 658).
С sdist так не выйдет — там лежит PKG-INFO, а не готовая METADATA, и
фронтенду приходится звать бэкенд, то есть выполнять чужой код. Смотрим,
что вообще внутри sdist:
import tarfile
sdist_path = glob.glob("demo-project/dist/*.tar.gz")[0]
with tarfile.open(sdist_path) as t:
for member in t.getnames():
print(member)
greet-0.1.0/uv.lock
greet-0.1.0/src/greet/__init__.py
greet-0.1.0/src/greet/cli.py
greet-0.1.0/tests/test_greet.py
greet-0.1.0/.gitignore
greet-0.1.0/README.md
greet-0.1.0/pyproject.toml
greet-0.1.0/PKG-INFO
Внутри sdist — дерево исходников плюс PKG-INFO: pyproject.toml,
tests/, README.md, uv.lock. В wheel из этого не попало ничего, там
только greet/*.py и dist-info/, ровно то, что распакуется в
site-packages/. Сами .py при этом идентичны: для чистого Python
«сборка» wheel — это копирование файлов плюс генерация метаданных.
Протокол сборки PEP 517 вживую#
Что делает uv build под капотом:
Читает
[build-system]— видитhatchling.build.Ставит
hatchlingв изолированное окружение сборки.import hatchling.build; hatchling.build.build_wheel(...).Бэкенд собирает файлы, кладёт wheel в
dist/.
Термин: хук (hook)
Функция с заранее оговорённым именем и сигнатурой, которую вызывает не
твой код, а тот, кто тебя запускает. Договорённость односторонняя: ты
объявляешь функцию с нужным именем — система сама находит её и зовёт в
нужный момент. Здесь стандарт требует от бэкенда функцию build_wheel;
pip импортирует модуль, ищет по имени и вызывает. Тот же приём —
pre-commit, который вешает свой хук на git commit (в конце лекции),
и sys.meta_path в системе импорта.
Бэкенд — обычный модуль, и хуки видно обычной интроспекцией:
%%bash
cd demo-project
uv run -q --no-project --with hatchling python -c "
import hatchling.build as backend
print('хуки PEP 517:')
for name in sorted(n for n in dir(backend) if n.startswith(('build_', 'get_', 'prepare_'))):
print(' ', name)
print()
print('build_wheel() вернул:', backend.build_wheel('/tmp/pep517-out'))
"
хуки PEP 517:
build_editable
build_sdist
build_wheel
get_requires_for_build_editable
g
et_requires_for_build_sdist
get_requires_for_build_wheel
prepare_metadata_for_build_editable
p
repare_metadata_for_build_wheel
build_wheel() вернул: greet-0.1.0-py3-none-any.whl
Ту же последовательность видно со стороны фронтенда, если попросить
pip быть многословным:
%%bash
rm -rf /tmp/build-verbose /tmp/pep517-out
python3 -m venv /tmp/build-verbose
/tmp/build-verbose/bin/pip install ./demo-project -v --progress-bar off --no-color 2>&1 \
| grep -E "Creating .*pip-build-env|Running command|Successfully built|Successfully installed" \
| sed 's/^ *//'
Running command installing build dependencies
Creating /private/var/folders/db/8q3n6yxn6l32mz6
r0frxg7v40000gn/T/pip-build-env-sz4skt8i/overlay/bin
Successfully installed hatchling-1.33.0
packaging-26.3 pathspec-1.1.1 pluggy-1.7.0 tomlkit-0.15.2 trove-classifiers-2026.9.21.13
Running co
mmand Getting requirements to build wheel
Running command Preparing metadata (pyproject.toml)
Runnin
g command Building wheel for greet (pyproject.toml)
Successfully built greet
Successfully installed
greet-0.1.0
Строки Running command ... — это и есть вызовы хуков
(get_requires_for_build_wheel, prepare_metadata_for_build_wheel,
build_wheel), а pip-build-env-* — то самое изолированное окружение
из шага 2.
4. PyPI — куда всё это едет#
PyPI (pypi.org) — центральный индекс Python-пакетов. Запущен в 2003, раньше назывался «cheese shop» — отсылка к Monty Python. Сколько там пакетов, можно не гадать, а посчитать: индекс отдаётся одной плоской страницей со ссылкой на каждый проект.
%%bash
curl -s https://pypi.org/simple/ | grep -c '<a href'
Process was interrupted.
---------------------------------------------------------------------------
CalledProcessError Traceback (most recent call last)
Cell In[22], line 1
----> 1 get_ipython().run_cell_magic('bash', '', "curl -s https://pypi.org/simple/ | grep -c '<a href'\n")
CalledProcessError: Command 'b"curl -s https://pypi.org/simple/ | grep -c '<a href'\n"' died with <Signals.SIGINT: 2>.
Когда пишешь pip install requests, фронтенд идёт именно сюда, в
Simple API (PEP 503). Только
теперь не в общий список, а на страницу конкретного проекта:
%%bash
# страница проекта — ссылки на все его файлы, от старых к новым
curl -s https://pypi.org/simple/requests/ > /tmp/simple-requests.html
echo "всего ссылок: $(grep -c '<a href' /tmp/simple-requests.html)"
echo "--- последние три файла ---"
grep -o '>requests-[^<]*</a>' /tmp/simple-requests.html | tail -3
echo "--- как выглядит одна ссылка (обрезано) ---"
grep -o '<a href="[^"]*"' /tmp/simple-requests.html | tail -1 | cut -c1-140
Все версии сразу, без разбиения на страницы: и sdist, и wheels, у каждой ссылки sha256 в якоре. Фронтенд качает эту страницу, находит самую свежую версию, выбирает файл под свою платформу.
JSON API — детально про пакет#
%%bash
curl -s https://pypi.org/pypi/requests/json | python3 -c "
import json, sys
d = json.load(sys.stdin)
print('name:', d['info']['name'])
print('version:', d['info']['version'])
print('requires_python:', d['info']['requires_python'])
print('license:', d['info']['license'])
print('summary:', d['info']['summary'])
print('project_urls:', list(d['info']['project_urls'].keys())[:5])
print('файлов у последней версии:', len(d['urls']))
"
Как фронтенд выбирает wheel#
У каждого интерпретатора свой список тегов, которые он готов принять:
!uv run -q --no-project --with packaging python -c "from packaging.tags import sys_tags; t = [str(x) for x in sys_tags()]; print(*t[:4], sep=chr(10)); print('...'); print('py3-none-any на позиции', t.index('py3-none-any'), 'из', len(t))"
Список упорядочен по предпочтению: сверху самые специфичные
(эта версия CPython, этот ABI, эта ОС, эта архитектура), внизу самые
общие (py3-none-any — любой Python 3 где угодно). Фронтенд идёт сверху
вниз и берёт первый тег, под который на индексе есть wheel; не нашёл ни
одного — качает sdist и собирает сам.
Смотрим, что фронтенд скачал бы для нас, без установки:
%%bash
rm -rf /tmp/pypi-demo
mkdir /tmp/pypi-demo
uvx pip download requests --no-deps -d /tmp/pypi-demo 2>&1 | tail -2
ls /tmp/pypi-demo/
requests — чистый Python, отдельная сборка под платформу ему не нужна,
поэтому совпадение нашлось на py3-none-any, почти в самом конце списка.
У numpy совпало бы на первых строчках.
Публикация — обратный путь#
Мы пока ничего не публикуем, но порядок действий одинаков для всех:
uv build # dist/*.tar.gz + dist/*.whl
uv publish # -> грузит в PyPI (API-токен с pypi.org/manage/account/token/)
# или классически:
uv tool install twine
twine upload dist/*
Test PyPI (test.pypi.org) — песочница для проверки перед продом:
uv publish --publish-url https://test.pypi.org/legacy/
pip install --index-url https://test.pypi.org/simple/ mypack
Приватные индексы#
Компании часто держат свой PyPI-совместимый индекс (Artifactory, Nexus,
devpi, pypiserver, GitHub Packages). Все совместимы с PEP 503, значит
работают с pip/uv из коробки:
pip install --index-url https://pypi.internal.company.com/simple/ mypack
# Или два индекса — сначала свой, потом публичный как запасной:
pip install --extra-index-url https://pypi.internal.company.com/simple/ mypack
Так же устроен PyTorch со сборками под CUDA — они лежат на своём индексе,
и ставить их надо через --index-url, а не --extra-index-url:
pip install torch --index-url https://download.pytorch.org/whl/cu128.
С --extra-index-url фронтенд считает оба индекса равноправными и берёт
версию повыше. На этом строится атака: злоумышленник публикует на PyPI
пакет с именем твоего внутреннего и версией побольше — и фронтенд
притащит чужой код вместо твоего.
Не только PyPI: git, архив, локальный путь#
pip умеет ставить не только с PyPI. Полезно, когда пакет не опубликован,
нужна ветка с фиксом, свой форк или один пакет из монорепозитория.
Прямо из git:
# Последний коммит main:
pip install git+https://github.com/user/repo.git
# Конкретная ветка / тег / SHA:
pip install git+https://github.com/user/repo.git@develop
pip install git+https://github.com/user/repo.git@v1.2.3
pip install git+https://github.com/user/repo.git@a1b2c3d
# Через SSH (если есть ключ):
pip install git+ssh://git@github.com/user/repo.git
# Пакет из подкаталога монорепозитория:
pip install "mypkg @ git+https://github.com/org/repo.git@main#subdirectory=mypkg"
Синтаксис имя @ URL — PEP 440, direct references.
Работает и в [project.dependencies], и на CLI, и в requirements.txt.
Но у прямой ссылки в [project.dependencies] есть цена: такой пакет
нельзя опубликовать на PyPI, загрузка отклоняется на уровне метаданных
(Can't have direct dependency).
Через архив по URL:
# GitHub генерирует .tar.gz для любой ссылки на коммит или тег:
pip install https://github.com/user/repo/archive/refs/heads/main.tar.gz
pip install https://github.com/user/repo/archive/refs/tags/v1.0.tar.gz
Быстрее, чем git+https: не тянется вся история.
Локальный путь:
pip install ./demo-project # обычная установка
pip install -e ./demo-project # в режиме редактирования
pip install './demo-project[test,lint]' # с extras; кавычки обязательны в zsh
Именно это делает pip install . — берёт . как локальный путь.
Воспроизводимость сборки: фиксируй хеш коммита
pip install git+https://…/repo.git@main — на каждой сборке разный
код. main двигается, и через месяц соберётся другой пакет.
Правильно — закрепить конкретный SHA:
mypkg @ git+https://github.com/org/repo.git@a1b2c3d#subdirectory=mypkg
Или ещё лучше — сначала тег или релиз с версией, потом == через
lock-файл. Git-URL с тегом — компромисс: тег теоретически можно
перевесить, но обычно не двигают.
Безопасность#
Тайпсквоттинг (typosquatting) — пакеты-двойники
Пакет requests — настоящий, официальный. А вот request (без -s),
requeests, python-requests — вредоносные копии с похожим именем.
Всегда сверяй имя перед установкой.
Реальные инциденты: colourama (2017, вместо colorama), копии
python-dateutil, десятки случаев в 2020-2024. Установка пакета —
это выполнение произвольного кода от его автора, доверяй только
известным именам.
Как защититься:
pip install --require-hashesплюсrequirements.txtс--hash=sha256:...: фронтенд сверит SHA256 скачанного файла с зафиксированным. Тот же принцип уuv.lock.Подписи. PEP 458 приняли, но на PyPI так и не включили. Что реально работает — PEP 740: с ноября 2024 PyPI умеет подтверждать, что пакет собран и залит из конкретного репозитория конкретным CI, а не руками с чьего-то ноутбука. Старые PGP-подписи, наоборот, убрали.
Установка из непроверенного sdist = запуск чужого
setup.py. Это одна из причин ухода от исполняемогоsetup.pyк декларативномуpyproject.toml.
Полезные команды#
!uv pip show --python /tmp/greet-cli/bin/python greet 2>/dev/null
show — установленная версия, метаданные и место (Location:), где
пакет физически лежит. Смотрим на greet в том venv, что собрали в
разделе 2.
!uv pip list --python /tmp/greet-cli/bin/python --format=freeze 2>/dev/null
list --format=freeze — то же, что классический pip freeze: плоский
список имя==версия, который можно скормить обратно в установку.
У pip команды те же: pip show <name>, pip freeze, pip check.
А pip index versions <name> (все версии на индексе) пока есть только
у pip — у uv pip аналога нет.
5. Установка: pip vs uv#
Выше было сказано, что pip кладёт в окружение сам python -m venv, а
uv venv этого не делает. Проверим:
%%bash
rm -rf /tmp/venv-std /tmp/venv-bare
python3 -m venv /tmp/venv-std
uv venv -q /tmp/venv-bare
echo "python -m venv:"; ls /tmp/venv-std/bin | grep "^pip" || echo " pip нет"
echo "uv venv:"; ls /tmp/venv-bare/bin | grep "^pip" || echo " pip нет"
Значит не рассчитывай на голую команду pip — зови uv pip либо
<venv>/bin/pip по полному пути.
Интерфейс у них общий, переучиваться не надо: uv pip install X делает
ровно то же, что pip install X, только заметно быстрее на большом
дереве зависимостей.
Сначала — виртуальное окружение#
Никогда не ставь пакеты в системный Python (/usr/bin/python или
Homebrew). Причины:
Права: установка в системный Python требует
sudo— легко накатить что-то, что сломает системные утилиты, зависящие от Python.Изоляция версий: проект A хочет
requests==2.28, B хочетrequests==2.31. В одном системном Python держать оба нельзя.Воспроизводимость:
pip freezeсистемного Python — свалка всего когда-либо поставленного, повторить её на другой машине невозможно.
Современные Linux-системы и Homebrew это прямо запрещают: установка
падает с externally-managed-environment
(PEP 668), и sudo не помогает.
Решение — виртуальное окружение (venv,
PEP 405). Это каталог с симлинком
на интерпретатор, своим site-packages/ и файлом pyvenv.cfg. Чтобы им
пользоваться, надо его активировать — подменить $PATH так, чтобы
python и pip шли из этого каталога:
%%bash
rm -rf /tmp/example-venv
python3 -m venv /tmp/example-venv
source /tmp/example-venv/bin/activate
which python pip
python -c "import sys; print(sys.prefix)"
Интерпретатор не копируется, а линкуется; стандартная библиотека не
копируется вообще — путь к ней берётся из pyvenv.cfg:
%%bash
ls -l /tmp/example-venv/bin/python*
cat /tmp/example-venv/pyvenv.cfg
ls /tmp/example-venv/lib/python*/
Внутри активированного venv pip install X кладёт X в
/tmp/example-venv/lib/python3.14/site-packages/, а не в системный.
Всё, что поставлено в venv, живёт только там: rm -rf /tmp/example-venv/
и всё удалено.
uv идёт дальше и делает venv за тебя: uv sync в проекте создаёт
.venv/, uv run запускает команду в нём без явной активации.
pip + venv (классика)#
%%bash
rm -rf /tmp/venv-pip
python3 -m venv /tmp/venv-pip
/tmp/venv-pip/bin/pip install --quiet --disable-pip-version-check -e './demo-project[test,lint]'
/tmp/venv-pip/bin/pip list 2>/dev/null | head -8
uv (современно)#
%%bash
rm -rf /tmp/venv-uv
uv venv -q /tmp/venv-uv
uv pip install -q --python /tmp/venv-uv/bin/python -e './demo-project[test,lint]'
uv pip list --python /tmp/venv-uv/bin/python 2>/dev/null | head -8
В курсе — оба
В CI курса задача ставится через pip install -e <task>, а зависимости
берутся из [test]. Интерфейс общий, так что локально подойдёт и
pip-venv, и uv-venv. Выбирай что удобнее: uv быстрее, pip есть в
любом venv, созданном штатным python -m venv.
Установка в режиме редактирования: зачем и почему#
Показываем на живом примере, что не так с обычной установкой при активной разработке.
Шаг 1. Копируем demo-project во временный каталог, чтобы
экспериментировать не портя оригинал. Ставим обычным способом,
проверяем что работает:
%%bash
rm -rf /tmp/greet-exp /tmp/venv-no-e
cp -r demo-project /tmp/greet-exp
uv venv -q /tmp/venv-no-e
uv pip install -q --python /tmp/venv-no-e/bin/python /tmp/greet-exp
/tmp/venv-no-e/bin/python -c "from greet import greet; print(greet('world'))"
Шаг 2. Правим исходник — меняем Hello, на HI,:
%%writefile /tmp/greet-exp/src/greet/__init__.py
def greet(name: str) -> str:
return f"HI, {name}!"
Исходник изменился. Но:
!/tmp/venv-no-e/bin/python -c "from greet import greet; print(greet('world'))"
По-прежнему Hello, world! — при установке код скопировался в
site-packages/, и там лежит старая версия. Правка в каталоге с
исходниками на установленный пакет не влияет.
Шаг 3. Что можно сделать — переустановить:
%%bash
uv pip install -q --python /tmp/venv-no-e/bin/python --reinstall --no-deps /tmp/greet-exp
/tmp/venv-no-e/bin/python -c "from greet import greet; print(greet('world'))"
Теперь HI, world!. Но представь, что цикл «правка -> тест» повторяется
каждые полминуты: переустанавливать пакет на каждой итерации невыносимо.
Лечение — -e#
Флаг -e ставит пакет в режиме редактирования (editable install).
Пакет как бы «ставится», но в site-packages/ попадает не копия, а
ссылка на каталог с исходниками. import идёт напрямую в твои файлы.
%%bash
rm -rf /tmp/venv-e
uv venv -q /tmp/venv-e
uv pip install -q --python /tmp/venv-e/bin/python -e /tmp/greet-exp
/tmp/venv-e/bin/python -c "from greet import greet; print(greet('world'))"
Правим исходник ещё раз — теперь HI, -> HEY,:
%%writefile /tmp/greet-exp/src/greet/__init__.py
def greet(name: str) -> str:
return f"HEY, {name}!"
!/tmp/venv-e/bin/python -c "from greet import greet; print(greet('world'))"
Сразу видит HEY, world! — никакой переустановки. Именно то, что
нужно для активной разработки.
Когда использовать
Разработка своего пакета — всегда
-e, чтобы правки не требовали переустановки.Отладка чужого форка —
-e ~/forks/somelibи правки прямо в клоне.Не для прода — при развёртывании всегда обычная установка, без
-e.
Под капотом: файл .pth (PEP 660)#
Механизм — файл .pth в site-packages/. Python при старте читает
все *.pth в этом каталоге и добавляет каждую строку как путь в
sys.path. Смотрим, что лежит в нашем venv:
!find /tmp/venv-e/lib -name "_editable_*.pth" -print -exec cat {} \;
Одна строка — абсолютный путь к нашему /tmp/greet-exp/src. При
import greet Python идёт по sys.path, находит
/tmp/greet-exp/src/greet/__init__.py и импортит оттуда, прямо
из наших исходников. Никаких копий.
Имя файла и его содержимое — на совести бэкенда: hatchling кладёт
_editable_impl_<name>.pth с путём внутри, setuptools —
__editable__.<name>-<version>.pth, и там для плоской раскладки будет
не путь, а строка import __editable___..._finder — она регистрирует
свой обработчик импорта в sys.meta_path, чтобы в sys.path не утёк
весь корень проекта вместе с tests/ и docs/. PEP 660 (2021)
стандартизировал только хук build_editable, не имена файлов; до него
каждый бэкенд городил своё.
Lock-файлы#
Зависимости в [project] — это диапазоны (requests>=2.31). Для
развёртывания нужны точные версии, иначе прод и машина разработчика
разъезжаются.
Два подхода:
pip freeze > requirements.txt— плоскийимя==версия. Без хешей, без extras, без маркеров окружения.uv.lock/poetry.lock/pdm.lock— у каждого инструмента свой формат: кросс-платформенный, с хешами, с разделением прямых зависимостей и транзитивных (тех, которые притащили за собой прямые). Но читает его только он сам.
Лочим копию проекта в /tmp — чтобы не трогать uv.lock, который лежит
в репозитории:
%%bash
rm -rf /tmp/greet-lock
cp -r demo-project /tmp/greet-lock
cd /tmp/greet-lock
rm -f uv.lock
uv lock -q
head -8 uv.lock
echo "всего строк: $(wc -l < uv.lock)"
Шапка — версия формата и requires-python. Ниже в ней перечислены
условия, при которых разрешение зависимостей расходится: lock один на
все ОС и версии Python, а не отдельный файл под каждую.
Дальше идёт по секции [[package]] на каждый пакет. Прочитаем его как
обычные данные:
import tomllib
from pathlib import Path
lock = tomllib.loads(Path("/tmp/greet-lock/uv.lock").read_text())
deps = [p for p in lock["package"] if p["name"] != "greet"]
print("пакетов кроме самого greet:", len(deps))
for p in deps:
print(f" {p['name']}=={p['version']} wheels с sha256: {len(p.get('wheels', []))}")
У greet в pyproject.toml было dependencies = [] и три имени в
extras — остальные приехали транзитивно. Версия у каждого зафиксирована
точно, у каждого файла есть sha256: uv sync на любой машине соберёт
ровно тот же venv и проверит хеши при скачивании.
Lock коммитим в git.
Стандартный формат: pylock.toml
Свои форматы взаимно нечитаемы, поэтому появился общий —
pylock.toml (PEP 751, 2025).
Родной lock он не заменяет, но экспортируют в него уже все:
uv export –format pylock.toml, pdm lock –format pylock,
hatch lock. Ставится через pip install -r pylock.toml.
Полные менеджеры: poetry, hatch, pdm, uv#
pip, uv pip, venv, hatchling — это отдельные слои. Некоторые
инструменты объединяют всё в один CLI: сборка, зависимости, venv,
публикация, lock-файл, повышение версии, установка самого Python.
Poetry (2018, Sébastien Eustace) — самый популярный менеджер полного
цикла. Свой lock-файл, до 2024 — свой формат [tool.poetry] вместо PEP 621.
!uv tool run -q --with poetry poetry --version
Hatch (2017, Ofek Lev / PyPA; переписан в v1 в 2022) — тоже полный
цикл, полностью на PEP 621. Сильная сторона — матрица окружений: в
[tool.hatch.envs] описываешь несколько сред (default, test, docs,
…), в каждой свой набор зависимостей и своя версия Python, дальше
hatch run test:pytest прогоняет тесты в каждой из них. Примерно то же,
что делал tox — инструмент того же класса, до сих пор живой.
!uv tool run -q --with hatch hatch --version
PDM (2020, Frost Ming) — начинался с эксперимента PEP 582
(__pypackages__ вместо venv, идея как у node_modules). Сейчас —
обычный CLI с lock-файлом и PEP 621 из коробки.
!uv tool run -q --with pdm pdm --version
uv (2024, Astral) — новый инструмент, вобрал лучшее из остальных.
Совместим с pip: uv pip install, uv pip list работают ровно как
pip, скрипты править не надо. Плюс полный цикл: uv init, uv add,
uv sync, uv run, uv publish, uv tool install (аналог pipx),
uv python install (аналог pyenv).
Одна и та же операция в разных инструментах:
Действие |
pip + venv |
poetry |
hatch |
pdm |
uv |
|---|---|---|---|---|---|
Создать проект |
|
|
|
|
|
Добавить зависимость |
|
|
правка |
|
|
Поставить проект |
|
|
|
|
|
Зафиксировать версии |
|
|
|
|
|
Запустить |
|
|
|
|
|
Опубликовать |
|
|
|
|
|
Lock-файл poetry несовместим с остальными
poetry.lock читает только poetry. hatch, pdm, uv, pip его не
понимают. Обратно тоже: poetry не читает чужие lock-файлы. Значит
перейти с poetry на что-то другое — это потерять lock-файл и
перегенерировать его заново.
Секция [tool.poetry] ещё встречается
Старые проекты на poetry используют свою секцию [tool.poetry] вместо
стандартной [project] из PEP 621. Внешне похоже, но синтаксис
зависимостей другой (^1.0 вместо >=1.0,<2.0 и так далее). Poetry 2+
понимает [project], но унаследованный код часто ещё на [tool.poetry].
Советы:
Новый проект —
uv. Один инструмент, быстрый, совместим сpip.Проект уже на poetry — оставайся. Миграция редко того стоит.
hatch— хорош для сложных проектов с несколькими окружениями (dev,docs, интеграционные тесты на разных версиях Python).pdm— нишевый. Единственная причина брать — если уже используешь.
Бэкенд и менеджер — независимые оси
hatchling.build (бэкенд, собирает wheel) и hatch (менеджер,
оркестрирует venv, зависимости и публикацию) — разные вещи с похожими
названиями. У нас в pyproject.toml бэкенд hatchling.build, а
менеджер uv — и это работает без проблем.
То же с poetry: poetry.core.masonry.api — только бэкенд, его можно
использовать без CLI poetry. Или взять бэкенд flit_core и менеджер
uv. Бэкенд и менеджер меняются независимо, важно только чтобы оба
понимали pyproject.toml.
6. Статический анализ#
Два инструмента, оба с первого дня курса: ruff и mypy --strict.
ruff — линтер и форматтер#
Заменяет black + isort + flake8 и большинство плагинов, работает
быстро на кодовой базе любого размера.
Плохой код:
%%writefile /tmp/bad.py
import os,sys
from typing import List, Dict
def greet( name:str )->str :
result="Hello, "+name
return result
x=greet("world")
print(x)
Линтер:
!uv run ruff check --output-format=concise /tmp/bad.py || true
Автоисправление:
!uv run ruff check --fix --output-format=concise /tmp/bad.py 2>&1 | tail -3
Форматтер (аналог black):
!uv run ruff format /tmp/bad.py && cat /tmp/bad.py
Конфиг:
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # assert в тестах разрешён
Не ставь select = [«ALL»]
Там есть правила, которые противоречат друг другу — один включённый запрещает то, что требует другой, и линтер начинает ругаться на любой код. Начинай с базового набора, расширяй по мере надобности.
mypy --strict#
Без --strict mypy молчит на многом:
%%writefile /tmp/untyped.py
def greet(name):
return "Hi " + name
greet(42) # int вместо str — но mypy без strict пропустит
Пустой --config-file= нужен, чтобы mypy не подхватил конфиг курса
(course-content/pyproject.toml), где strict уже включён:
!uv run mypy --config-file= /tmp/untyped.py
Со --strict — ловит:
!uv run mypy --config-file= --strict /tmp/untyped.py || true
Чиним — расставляем типы:
%%writefile /tmp/typed.py
def greet(name: str) -> str:
return "Hi " + name
greet("world")
!uv run mypy --config-file= --strict /tmp/typed.py
Конфиг:
[tool.mypy]
python_version = "3.14"
strict = true
[[tool.mypy.overrides]]
module = ["legacy.*"] # постепенный переход: сюда ещё не добрались
ignore_errors = true
[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"] # чужие библиотеки без заглушек (stubs)
ignore_missing_imports = true
Подробно про типы — лекция 2.
pre-commit — проверки локально (необязательно)#
Хочется, чтобы ruff и mypy проверяли код до git push, а не в CI?
Стандартный инструмент — pre-commit. Он ставит
git-хук, который перед git commit прогоняет любые проверки. Пример
.pre-commit-config.yaml:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.8
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.3.1
hooks:
- id: mypy
additional_dependencies: [pytest]
Установка:
uv tool install pre-commit # аналог pipx: свой изолированный venv
pre-commit install # ставит хук в .git/hooks
В курсе не требуется
Мы не кладём pre-commit в шаблон задач. Кто хочет, настраивает у себя в форке; коммитить
.pre-commit-config.yaml в публичный репозиторий не нужно — это твой личный инструмент.

