Лекция 1. Packaging и tooling#

0. О курсе#

Команда#

Лектор — Иван Лущ.

Программа#

Курс идёт от языка к системе — 13 лекций:

  • Блок I. Фундамент + инженерия — packaging, типы, тесты, отладка (1-4).

  • Блок II. Concurrency — threading, multiprocessing, async (5-7).

  • Блок III. Метапрограммирование — дескрипторы, метаклассы (8-9).

  • Блок IV. Внутренности CPython — JIT, memory/GC, profiling (10-12).

  • Блок V. Наружу — C-extensions и bindings (13).

Материалы и связь#

Задачи, скорборд и дедлайны — manytask (поверх SourceCraft, регистрация через Яндекс ID; при регистрации создаётся ваш приватный форк — туда пушите).

Оценка#

Oитог = 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.


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 — pure Python, работает где угодно.

  • numpy-2.1.0-cp314-cp314-macosx_15_0_arm64.whl — только CPython 3.14 ABI, только macOS 15+ на arm64.

Логика pip install foo:

  1. Ищет wheel под твою платформу, качает и распаковывает.

  2. Если wheel’а нет, качает sdist и собирает wheel локально, потом ставит.

  3. pip install . делает то же самое, только берёт source из локальной директории.

Значит наш 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) — интерфейс frontend’а (pip) с backend’ом (setuptools/hatchling/…).

  • PEP 621 (2020) — [project]: стандартные метаданные, одинаковые для всех backend’ов.

Когда стало работать

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 = { text = "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]
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 — что нужно на моменте сборки (не в runtime!). Обычно сам backend + иногда cython/cmake для extensions.

  • build-backend — модуль который вызывается frontend’ом. Экспортирует функции build_sdist, build_wheel, build_editable.

Frontend (pip, uv, python -m build) не знает как собирать твой код. Он делегирует backend’у через стандартный API PEP 517:

pip install .
   -> читает [build-system] из pyproject.toml
   -> ставит `requires` в изолированный build-env
   -> import build-backend, вызывает build_wheel()
   -> получает .whl, ставит в целевой venv

Изолированный build-env

pip создаёт временный venv в /tmp/pip-build-env-*, ставит туда requires, вызывает backend оттуда. После сборки удаляется. Изоляция — чтобы твоя старая версия setuptools не сломала сборку зависимости которая требует свежую.

Backend’ов много, зачем?#

Три причины. Первая: PEP 517/518 стандартизировали интерфейс между frontend’ом и backend’ом, и появилась возможность писать альтернативы setuptools (до этого он был единственным). Вторая: разным типам кода нужны разные инструменты. Третья: некоторые backends идут в связке со своим CLI (poetry, hatch, pdm) и претендуют на весь workflow.

Дерево выбора:

  • Pure Pythonhatchling (по умолчанию в 2026). Минималистично — flit_core.

  • C/C++ extensions простыеsetuptools (legacy, много примеров).

  • C/C++ сложные с CMakescikit-build-core.

  • Rust через PyO3maturin (единственный разумный выбор).

  • Уже в экосистеме poetry/pdm — их родные backends.

Мы используем hatchling — pure Python, PEP 621 native, без своих частных секций.

Подробнее про manager vs backend написано ниже; пока просто запомни: hatchling.build это backend (мы им пользуемся), а hatch это отдельный CLI-инструмент, названия у них просто похожие.

Frontend’ов тоже несколько#

Frontend — тот кто вызывает backend через PEP 517 API. Два подвида:

Install-frontend — ставит пакет в venv (frontend → backend вернёт wheel → frontend распакует в site-packages):

  • pip install — из stdlib, классика, работает всегда.

  • uv pip install / uv add — тот же интерфейс что у pip, но заметно быстрее на больших зависимостях.

  • pipx install — для standalone-CLI утилит, каждый в свой изолированный venv.

  • poetry install / pdm install / hatch install — в рамках своих workflow.

Build-frontend — только собирает dist/*.tar.gz + dist/*.whl, не ставит:

  • python -m build — референсная реализация от PyPA, минимальная.

  • uv build — то же, входит в комплект uv.

  • hatch build / poetry build / pdm build — в рамках их экосистем.

Живая проверка что все build-frontends равнозначны (собирают тот же wheel из одного pyproject.toml):

%%bash
# 1. Референсный build-frontend от PyPA - тянем `build` через `uv run --with`
# (temp-install только на время вызова, ничего не оседает в 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.32.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) = 01e6c56739512cb5bcaecf4c...
  sha256(b) = 01e6c56739512cb5bcaecf4c...
  identical: True

greet-0.1.0.tar.gz
  sha256(a) = a50d4de4124f5d1a7e3099fd...
  sha256(b) = 7e19f34bdefd35b520917fc8...
  identical: False
  • .whl — hash совпадает. Wheel — обычный zip, hatchling при сборке пинит timestamps всех записей на фиксированную дату (2020-02-02), поэтому один и тот же исходник даёт побайтово одинаковый файл в любом frontend’е. Это стандарт Reproducible Builds: разные машины/пользователи получают идентичный артефакт → можно сверять по hash что никто не подменил wheel в дистрибуции.

  • .tar.gz — hash различается. Внутри tar-архива timestamps файлов тоже пришпилены (hatchling делает то же самое). Но gzip- обёртка вокруг tar записывает в свой заголовок время сжатия — оно каждый раз своё, отсюда разный итоговый файл. Это ограничение gzip как формата, а не самого backend’а — распакованное содержимое идентично побайтово.

Итог: frontend’ы взаимозаменяемы, они говорят с одним и тем же hatchling.build через PEP 517 API. За воспроизводимость отвечает backend, а не frontend.

Полный набор из pipe-line’а

Пакетный workflow разбит на слои. Каждый — независимая ось выбора:

  • Manager: pip+venv / uv / poetry / hatch / pdm — что оркестрирует.

  • Build-frontend: python -m build / uv build / <manager> build — что вызывает backend.

  • Backend: hatchling / setuptools / flit_core / maturin — что собирает.

  • Install-frontend: pip install / uv pip install — что кладёт wheel в venv.

Комбинации свободные. У нас: hatchling backend + uv manager (и install, и build).

[project] — метаданные PEP 621#

!sed -n '5,20p' demo-project/pyproject.toml
[project]
name = "greet"
version = "0.1.0"
description = "Тривиальный пакет для демонстрации packaging"
readme = "README.md"
requires-python = ">=3.14"
authors = [{ name = "Иван Лущ", email = "ch0p1k3@yandex.ru" }]
license = { text = "MIT" }
dependencies = []

[project.optional-dependencies]
test = ["pytest>=8"]
lint = ["ruff>=0.8", "mypy>=1.13"]

[project.scripts]
greet = "greet.cli:main"

Полный набор полей:

[project]
name = "greet"                       # PyPI name
version = "0.1.0"                    # SemVer, или dynamic
description = "..."                  # для PyPI карточки
readme = "README.md"
requires-python = ">=3.14"
license = { text = "MIT" }           # или { file = "LICENSE" }
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 508: syntax зависимостей#

requests>=2.31,<3.0
click~=8.1                            # compatible release == >=8.1,<9.0
numpy>=1.26; python_version >= "3.14" # environment marker
urllib3[secure]>=2.0                  # extras

~=X.Y = >=X.Y,<(X+1).0. Markers: 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"]   # self-ref

Ставится pip install .[test,lint].

CLI entry points#

[project.scripts]
greet = "greet.cli:main"

После установки в venv появляется исполняемый greet в bin/:

%%bash
rm -rf /tmp/greet-cli
python3 -m venv /tmp/greet-cli
/tmp/greet-cli/bin/pip install --quiet ./demo-project
/tmp/greet-cli/bin/greet Ivan
[notice] A new release of pip is available: 2
6.1.1 -> 26.2.1
[notice] To up
date, run: /private/tmp/greet-cli/bin/python3.14 -m pip install --upgrade pip
Hello, Ivan!

Что внутри bin/greet:

!head -5 /tmp/greet-cli/bin/greet
#!/private/tmp/greet-cli/bin/python3.14
import sys
from greet.cli import main
if __name__ == '__main__':
    sys.argv[0] = sys.argv[0].removesuffix('.exe')

[tool.*] — конфиг тулов#

Конвенция PEP 518: любой тул читает [tool.<name>]. У нас в demo:

!grep -A2 '^\[tool' demo-project/pyproject.toml
[tool.hatch.build.targets.wheel]
packages = ["src/greet"]

[tool.hatch.build.targets.sdist]
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.

Наша задача: сделать этот код installable. Всё что нужно — один файл 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 = { text = "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
python3 -m venv /tmp/venv-ecom
/tmp/venv-ecom/bin/pip install --quiet ./demo-ecommerce
/tmp/venv-ecom/bin/python demo-ecommerce/main.py
[notice] A new release of pip is available: 2
6.1.1 -> 26.2.1
[notice] To up
date, run: /private/tmp/venv-ecom/bin/python3.14 -m pip install --upgrade pip
Загружается модуль `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 (свой + parent’s). import = выполнение кода.

Почему __init__.py не должен делать много

Каждый import тянет весь этот граф. Если в __init__.py тяжёлая инициализация (открытие БД, чтение файлов, network) — она произойдёт при первом импорте, часто в неожиданный момент. Держи __init__.py чистым (re-exports + __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. Сборка через backend — потрошим 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 800
drwxr-xr-x   5 chopik  staff     160 Sep  7 08:07 .
drwxr-xr-x  10 chopik  staf
f     320 Sep  7 08:07 ..
-rw-r--r--   1 chopik  staff       1 Sep  7 08:07 .gitignore
-r
w-r--r--   1 chopik  staff    1813 Sep  7 08:07 greet-0.1.0-py3-none-any.whl
-rw-r--r--   1 chopik  
staff  400942 Sep  7 08:07 greet-0.1.0.tar.gz

Wheel — это ZIP. Смотрим через zipfile из stdlib (никакого 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, headers-like).

  • WHEEL — версия формата + platform tags.

  • RECORD — CSV path,hash,size для integrity при uninstall.

  • 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: 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 читает при pip install — без запуска никакого Python-кода.

Что в 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/dist-a/greet-0.1.0-py3-none-any.whl
greet-0.1.0/dist-a/greet-0.1.0.tar.gz
greet-0.1.0/dist-b/.gitignore
greet-0.1.0/dist-b/greet-0.1.0-py3-none-any.whl
greet-0.1.0/dist-b/greet-0.1.0.tar.gz
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 — исходники (в отличие от wheel где только собранные .py плюс metadata).

PEP 517 build protocol вживую#

Что делает uv build под капотом:

  1. Читает [build-system] — видит hatchling.build.

  2. Ставит hatchling в изолированный build-env.

  3. import hatchling.build; hatchling.build.build_wheel(...).

  4. Backend собирает файлы, кладёт wheel в dist/.

Смотрим шаги через pip install -v:

%%bash
rm -rf /tmp/build-verbose
python3 -m venv /tmp/build-verbose
/tmp/build-verbose/bin/pip install ./demo-project -v 2>&1 \
    | grep -E "^(Created temporary|Running|Building|Installing|Successfully)" \
    | head -20
Building wheels for collected packages: greet
Successfully built greet
Installing collected packages
: greet
Successfully installed greet-0.1.0

4. PyPI — куда всё это едет#

PyPI (pypi.org) — центральный индекс Python-пакетов. ~600k пакетов в 2026, ~1M загрузок в секунду. Основан 2003, ранее назывался «cheese shop» (Monty Python отсылка).

Когда пишешь pip install requests — pip идёт именно сюда. Как именно? Через Simple API (PEP 503):

%%bash
# Simple API — просто HTML-страница со списком wheels пакета
curl -s https://pypi.org/simple/requests/ | head -20
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta name="pypi:repository-version" content="1.4">
<m
eta name="pypi:project-status" content="active">    <title>Links for requests</title>
  </head>
  <b
ody>
    <h1>Links for requests</h1>
<a href="https://files.pythonhosted.org/packages/ba/bb/dfa0141a
32d773c47e4dede1a617c59a23b74dd302e449cf85413fc96bc4/requests-0.2.0.tar.gz#sha256=813202ace4d9301a3c
00740c700e012fb9f3f8c73ddcfe02ab558a8df6f175fd" >requests-0.2.0.tar.gz</a><br />
<a href="https://fi
les.pythonhosted.org/packages/4b/ad/d536b2e572e843fda13e4458c67f937b05ce359722c1e4cdad35ba05b6e3/req
uests-0.2.1.tar.gz#sha256=d54eb33499f018fc6bd297613bf866f8d134629c8e02964aab6ef951f460e41e" >request
s-0.2.1.tar.gz</a><br />
<a href="https://files.pythonhosted.org/packages/82/3c/3b5beca192da920c0c2b
a67119d66ba1e4b1e766f40898e5e684d697ca1c/requests-0.2.2.tar.gz#sha256=b3289694b2ddf6adb4f7e1f470b977
1330c76125611222b9c702f0e2e9733cbc" >requests-0.2.2.tar.gz</a><br />
<a href="https://files.pythonho
sted.org/packages/6f/7e/5c2d7d9102c6ab847bd1215f96255e894fbfc81c8abf2c1714ae2a504913/requests-0.2.3.
tar.gz#sha256=8e374b75aaae7f85325e9bb126e96cb77a3bfc17e81ee74a0e96916aac1cc2ba" >requests-0.2.3.tar.
gz</a><br />
<a href="https://files.pythonhosted.org/packages/dc/02/789859c27162bb91ecf6b72ed4ce1af3
ed1710255265ad0901c4d4e25666/requests-0.2.4.tar.gz#sha256=ef1bd1a81022e9bf574ecfe69cbd8597e79371b890
d29bd3847dd946102c8eed" >requests-0.2.4.tar.gz</a><br />
<a href="https://files.pythonhosted.org/pac
kages/96/2b/88e9d6bf2e9d75cda77bf4fdc03720f4ba262beb532f9510a4a7f3e45660/requests-0.3.0.tar.gz#sha25
6=57eed745eb2a2e3c7e1dd935ccd49eb2eac51cfcdace4a97fb44de5da70f0035" >requests-0.3.0.tar.gz</a><br />
<a href="https://files.pythonhosted.org/packages/5e/c0/76fac9445cd8b6394eacae1e098ca0c97767cc0112e4
5e68521f553df003/requests-0.3.1.tar.gz#sha256=05dddfd656d25b7738778d2b4e8fa72e53b5357a2f80a319e6e1fa
59edb03339" >requests-0.3.1.tar.gz</a><br />
<a href="https://files.pythonhosted.org/packages/d5/f1/
16b57088f11cd5c6c82834bad6475826309cee44edaae860e9f65c084703/requests-0.3.2.tar.gz#sha256=78ecf812ee
865b62be106100a3c6f24058c7901ad995351b8818f18ea97ce848" >requests-0.3.2.tar.gz</a><br />
<a href="ht
tps://files.pythonhosted.org/packages/f1/64/8a2ba81294381bb90e8fb4b6fa750e0dca3f2d19e8caaeeae5e7bb6b
3753/requests-0.3.3.tar.gz#sha256=ccbbc41c4c009baecf41e993727048c65c440fefadb217b11e73f63cd0cae09a" 
>requests-0.3.3.tar.gz</a><br />
<a href="https://files.pythonhosted.org/packages/ed/1b/8682a0cfe92f
67e30fb9ac7982cb785a1230ca4385dc1353513f5b87b9f4/requests-0.3.4.tar.gz#sha256=e72a42a0317f33114b48c9
72d3056bad3265b92450d4e0e51ad0b384e43bc6d9" >requests-0.3.4.tar.gz</a><br />
<a href="https://files.
pythonhosted.org/packages/56/c3/0887d5d6c18a366308b3dc7024210b4c89ff9ae92ae5fb87cf8fe58bcae2/request
s-0.4.0.tar.gz#sha256=35185852569456de25a654c5f9a43a1b8e4dc18a2a676985bbb9d5e7e5a9703e" >requests-0.
4.0.tar.gz</a><br />
<a href="https://files.pythonhosted.org/packages/b3/54/dbc9b89a66a15ab9f3e2595d
e1b1ebd1da954efcb30a329c98710e014c05/requests-0.4.1.tar.gz#sha256=f978616765803e9e0e9943136b34be0da6
9d74ba8fbd064cbfcf28f33ca54d8a" >requests-0.4.1.tar.gz</a><br />

Список ссылок на wheels и sdist. Pip качает эту страницу, находит самую свежую версию, выбирает wheel под твою платформу, качает.

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('files (latest):', len(d['urls']), 'вариантов wheel/sdist')
"
name: requests
version: 2.34.2
requires_python: >=3.10
license: Apache-2.0
summary: Python HTTP for 
Humans.
project_urls: ['Documentation', 'Source']
files (latest): 2 вариантов wheel/sdist

Как pip выбирает wheel#

Смотрим что pip скачал бы для нас, без установки:

%%bash
rm -rf /tmp/pypi-demo
mkdir /tmp/pypi-demo
pip download requests --no-deps -d /tmp/pypi-demo 2>&1 | tail -3
ls /tmp/pypi-demo/
bash: line 3: pip: command not found

Выбрал requests-*.whl под нашу платформу (py3-none-any — pure Python, работает везде).

Публикация — обратный путь#

Мы пока ничего не публикуем, но flow тот же для всех:

uv build                  # dist/*.tar.gz + dist/*.whl
uv publish                # → грузит в PyPI (API token из pypi.org/manage/account/token/)
# или классически:
pip install twine
twine upload dist/*

Test PyPI (test.pypi.org) — sandbox для проверки перед прод:

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
# Или два индекса — сначала свой, потом public fallback:
pip install --extra-index-url https://pypi.internal.company.com/simple/ mypack

Аналогично для PyTorch с CUDA-wheels — они на своём индексе, pip install torch --extra-index-url https://download.pytorch.org/whl/cu124.

Не только PyPI: git, tarball, local path#

pip умеет ставить не только с PyPI. Полезно когда: пакет не опубликован, нужна dev-ветка, форк с фиксом, monorepo с несколькими пакетами внутри.

Прямо из 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

# Пакет из subdirectory monorepo:
pip install "mypkg @ git+https://github.com/org/repo.git@main#subdirectory=mypkg"

Синтаксис имя @ URLPEP 508 direct URL. Работает и в pyproject.toml.dependencies, и на CLI, и в requirements.txt.

Через tarball / архив URL:

# GitHub генерирует .tar.gz для любого ref:
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 (не тянет всю историю).

Local path:

pip install ./demo-project              # обычная установка
pip install -e ./demo-project           # editable
pip install ./demo-project[test,lint]   # с extras

Именно это делает pip install . — берёт . как local path.

Воспроизводимость сборки: фиксируй commit hash

pip install git+https://.../repo.git@mainна каждой сборке разный код. main двигается, и через месяц соберётся другой пакет.

Правильно — закрепить конкретный SHA:

mypkg @ git+https://github.com/org/repo.git@a1b2c3d#subdirectory=mypkg

Или ещё лучше — сначала tag/release с версией, потом использовать == через lockfile. Git-URL с тегом — компромисс (тег теоретически можно перевесить, но обычно не двигают).

Security#

Typosquatting

Пакет requests — настоящий, официальный. А вот request (без -s), requeests, python-requests — вредоносные копии с похожим именем. Всегда сверяй имя перед pip install.

Реальные инциденты: colourama (2017, вместо colorama), python-dateutil копии, десятки в 2020-2024. pip install = выполнение произвольного кода от автора пакета — доверяй только известным.

Как защититься:

  • pip install --require-hashes + requirements.txt с --hash=sha256:... — pip сверит SHA256 wheel’а с зафиксированным. Тот же принцип у uv.lock.

  • Подписанные пакеты (PEP 458 — в процессе внедрения) — TUF-based подписи, работа PSF/Warehouse.

  • pip install . из непроверенного sdist = запуск чужого setup.py. Это одна из причин ухода от executable setup.py к декларативному pyproject.toml.

Полезные команды#

!pip show requests 2>&1 | head -8
fish: Unknown command: pip
fish: 
pip show requests 2>&1 | head -8
^~^

pip show <name> — локально установленная версия + metadata + место (Location: ...).

pip index versions requests   # все доступные версии
pip show --files requests     # какие файлы установились
pip check                     # broken dependencies

5. Установка: pip vs uv#

pip — это install-frontend из stdlib, работает всегда. uv даёт тот же интерфейс и заметно быстрее на больших зависимостях. Учиться командам заново не надо, uv pip install X делает ровно то же, что pip install X.

Сначала — виртуальное окружение#

Никогда не ставь пакеты в системный Python (/usr/bin/python или Homebrew). Причины:

  • Права: pip install в системный Python требует sudo — легко накатить что-то, что сломает утилиты системы, зависящие от Python.

  • Изоляция версий: проект A хочет requests==2.28, B хочет requests==2.31. В одном системном Python держать оба нельзя.

  • Reproducibility: pip freeze системного Python — свалка всего когда- либо ставленного, воспроизвести на другой машине невозможно.

Решение — виртуальное окружение (venv, PEP 405). Это отдельный каталог с копией Python и своим site-packages/. Чтобы им пользоваться, надо его активировать — подменить $PATH так, чтобы python и pip шли из этого каталога:

%%bash
python3 -m venv /tmp/example-venv
source /tmp/example-venv/bin/activate
which python pip
python -c "import sys; print(sys.prefix)"
/tmp/example-venv/bin/python
/tmp/example-venv/bin/pip
/private/tmp/example-venv

Внутри активированного venv pip install X кладёт X в /tmp/example-venv/lib/pythonX.Y/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 -e './demo-project[test,lint]'
/tmp/venv-pip/bin/pip list | head -10
[notice] A new release of pip is available: 2
6.1.1 -> 26.2.1
[notice] To up
date, run: /private/tmp/venv-pip/bin/python3.14 -m pip install --upgrade pip
Package           Version Editable project location
----------------- ------- ----------------------
---------------------------------------------------------------------------
ast_serialize     0.9.0
greet             0.1.0   /Users/chopik/advanced-python-ami-lectures-2026/course-content/lectures/01
-packaging/demo-project
iniconfig         2.3.0
librt             0.15.0
mypy              2.3.1
myp
y_extensions   1.1.0
packaging         26.3
pathspec          1.1.1

uv (современно)#

%%bash
rm -rf /tmp/venv-uv
uv venv /tmp/venv-uv
uv pip install --python /tmp/venv-uv/bin/python -e './demo-project[test,lint]' --quiet
uv pip list --python /tmp/venv-uv/bin/python | head -10
Using CPython 3.14.5 interpreter at: /opt/homebrew/opt/python@3.14/bin/python3.14
Creating virtual environment at: /tmp/venv-uv
Activate with: source /tmp/venv-uv/bin/activate.fish
Using Python 3.14.5 environment at: /private/tmp/venv-uv
Package           Version Editable project location
----------------- ------- ----------------------
---------------------------------------------------------------------------
ast-serialize     0.9.0
greet             0.1.0   /Users/chopik/advanced-python-ami-lectures-2026/course-content/lectures/01
-packaging/demo-project
iniconfig         2.3.0
librt             0.15.0
mypy              2.3.1
myp
y-extensions   1.1.0
packaging         26.3
pathspec          1.1.1

В курсе — оба

Проверяющий скрипт использует pip install .[test], интерфейс общий — подходит и pip-venv, и uv-venv. Выбирайте что удобнее; uv быстрее, pip без внешних зависимостей.

Editable install: зачем и почему#

Показываем на живом примере что не так с обычным pip install . для активной разработки.

Шаг 1. Копируем demo-project во временную директорию (чтобы экспериментировать не портя оригинал), ставим обычным способом, проверяем что работает:

%%bash
rm -rf /tmp/greet-exp /tmp/venv-no-e
cp -r demo-project /tmp/greet-exp

python3 -m venv /tmp/venv-no-e
/tmp/venv-no-e/bin/pip install --quiet /tmp/greet-exp
/tmp/venv-no-e/bin/python -c "from greet import greet; print(greet('world'))"
[notice] A new release of pip is available: 2
6.1.1 -> 26.2.1
[notice] To up
date, run: /private/tmp/venv-no-e/bin/python3.14 -m pip install --upgrade pip
Hello, world!

Шаг 2. Правим исходник — меняем Hello, на HI,:

!perl -i -pe 's/Hello,/HI,/' /tmp/greet-exp/src/greet/__init__.py
!cat /tmp/greet-exp/src/greet/__init__.py
def greet(name: str) -> str:
    return f"HI, {name}!"

Source изменился. Но:

!/tmp/venv-no-e/bin/python -c "from greet import greet; print(greet('world'))"
Hello, world!

По-прежнему Hello, world! — потому что pip install . скопировал код в site-packages/ при установке, там лежит старая версия. Наш edit в source-каталоге к установленному пакету не относится.

Шаг 3. Что можно сделать — переустановить:

%%bash
/tmp/venv-no-e/bin/pip install --quiet --force-reinstall --no-deps /tmp/greet-exp
/tmp/venv-no-e/bin/python -c "from greet import greet; print(greet('world'))"
[notice] A new release of pip is available: 2
6.1.1 -> 26.2.1
[notice] To up
date, run: /private/tmp/venv-no-e/bin/python3.14 -m pip install --upgrade pip
HI, world!

Теперь HI, world!. Но представь что цикл «правка → тест» происходит каждые 30 секунд — каждый раз переустанавливать невыносимо.

Лечение — -e#

pip install -e /patheditable install. Пакет как бы «ставится», но site-packages/ содержит не копию, а ссылку на source-каталог. import идёт напрямую в твои файлы.

%%bash
rm -rf /tmp/venv-e
python3 -m venv /tmp/venv-e
/tmp/venv-e/bin/pip install --quiet -e /tmp/greet-exp
/tmp/venv-e/bin/python -c "from greet import greet; print(greet('world'))"
[notice] A new release of pip is available: 2
6.1.1 -> 26.2.1
[notice] To up
date, run: /private/tmp/venv-e/bin/python3.14 -m pip install --upgrade pip
HI, world!

Правим source ещё раз — теперь HI, -> HEY,:

!perl -i -pe 's/HI,/HEY,/' /tmp/greet-exp/src/greet/__init__.py
!/tmp/venv-e/bin/python -c "from greet import greet; print(greet('world'))"
HEY, world!

Сразу видит HEY, world! — никакой переустановки. Именно то что нужно для активной разработки.

Когда использовать

  • Разработка своего пакета — всегда -e, чтобы правки не требовали переустановки.

  • Дебаг чужого форка-e ~/forks/somelib + правки прямо в клоне.

  • Не для прода — deploy всегда обычный pip install . (без -e).

Под капотом: .pth файл (PEP 660)#

Механизм — файл .pth в site-packages/. Python при старте читает все *.pth в этом каталоге и добавляет каждую строку как путь в sys.path. У нас в venv-e:

!find /tmp/venv-e/lib -name "__editable__*.pth" -exec cat {} \;

Одна строка — абсолютный путь к нашему /tmp/greet-exp/src. При import greet Python идёт по sys.path, находит /tmp/greet-exp/src/greet/__init__.py и импортит оттуда — прямо из наших исходников. Никаких копий.

PEP 660 (2021) стандартизировал API editable install между backend’ами — до этого каждый (setuptools, poetry, flit) реализовывал по-своему.

Lock files#

pyproject.toml.dependenciesдиапазоны (requests>=2.31). Для deploy нужны точные версии — иначе прод и dev расходятся.

Два подхода:

  • pip freeze > requirements.txt — плоский name==version. Без hashes, без extras, без markers.

  • uv.lock / poetry.lock / pdm.lock — универсальный формат, кросс-платформенный, с hashes, разделение direct/transitive.

%%bash
cd demo-project
rm -f uv.lock
uv lock --quiet 2>&1 || true
head -25 uv.lock 2>/dev/null | head -20
version = 1
revision = 3
requires-python = ">=3.14"
resolution-markers = [
    "python_full_version 
>= '3.15'",
    "python_full_version < '3.15'",
]

[[package]]
name = "ast-serialize"
version = "0.9
.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.or
g/packages/fd/c0/5bb6885a9608d86ee5712c0d88bc405d3a49f3e44231576e130ea2f53d34/ast_serialize-0.9.0.ta
r.gz", hash = "sha256:79fe8be1c934aa572940d1811d8dbe4d1b6f22291e3f16755c9b062e9ac92fb7", size = 9512
93, upload-time = "2026-09-02T15:50:45.078Z" }
wheels = [
    { url = "https://files.pythonhosted.or
g/packages/0e/76/497f19d9bdb3899a1efd82e2957f455d0c6e0cb9ebbc254735acb1f74235/ast_serialize-0.9.0-cp
314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ae1c46eb97865823f9843c4b80145e011874923e1a
4a44b45738a5309d83e9f5", size = 889442, upload-time = "2026-09-02T15:49:21.144Z" },
    { url = "htt
ps://files.pythonhosted.org/packages/2d/c5/9fb64b7106c5534739322c74be7b743c4f2e3b5fd05d5b8e677f05c54
d5f/ast_serialize-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af082cb7e6c4fa3a428aa6
16c13d709a944076eba84a73184de15621cc1a915d", size = 1226721, upload-time = "2026-09-02T15:49:22.612Z
" },
    { url = "https://files.pythonhosted.org/packages/27/67/b550fc81aa0133808410783c6d9a1b925e31
610d226e836e21337850af55/ast_serialize-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e9
f2540741ad10657a209209f7e5cc6b530eb3ed145fd77258ab43542d96ad7", size = 1207369, upload-time = "2026-
09-02T15:49:23.916Z" },
    { url = "https://files.pythonhosted.org/packages/5c/1e/ed9e66deb7da63e44
d0c0fd3a8feef698882ed56ea521a29494d4616eb46/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.
manylinux2014_aarch64.whl", hash = "sha256:a95485d5e8704af2ecc7f723757b88f992ae8122028d687ccf877cad2
b4c3da4", size = 1273073, upload-time = "2026-09-02T15:49:25.336Z" },
    { url = "https://files.pyt
honhosted.org/packages/68/8f/cd337551d7a68c982425bbf91f183943d7ccc62394002c74807a7f0e60db/ast_serial
ize-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b559cffac5a71
a698d9194e4295765ff2132a10fd1284860f02b30f12c1f729e", size = 1279045, upload-time = "2026-09-02T15:4
9:26.75Z" },
    { url = "https://files.pythonhosted.org/packages/40/c6/98dc41eb4122d5da83241e805739
838ed59e1e1b9006cbed89ded635a17f/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux20
14_ppc64le.whl", hash = "sha256:dad8a3f7106efcf252fc289c092ee0cee5c3512c0088bcf3fffa01458323092f", s
ize = 1539300, upload-time = "2026-09-02T15:49:28.213Z" },

Lock коммитим в git. uv sync на любой машине даст тот же venv.

Полные менеджеры: poetry, hatch, pdm, uv#

pip, uv pip, venv, hatchling — это отдельные слои. Некоторые tools объединяют всё в один CLI: сборка + deps + venv + publish + lock + version bump + Python-версия.

Poetry (2018, Sébastien Eustace) — самый популярный full-workflow. Свой lock, до 2024 свой [tool.poetry] формат вместо PEP 621.

!uv tool run --with poetry poetry --version
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0/0
 Resolving dependencies...                                                     
 Resolving dependencies...                                                     
 Resolving dependencies...                                                     
 poetry==2.4.3                                                                 
 poetry-core==2.4.0                                                            
 build==1.6.0                                                                  
 cachecontrol==0.14.4                                                          
 cachecontrol==0.14.4                                                          
 cleo==2.1.0                                                                   
 dulwich==1.2.14                                                               
 fastjsonschema==2.22.2                                                        
 findpython==0.8.0                                                             
 installer==1.0.1                                                              
 keyring==25.7.0                                                               
 packaging==26.3                                                               
 pbs-installer==2026.9.1                                                       
 pbs-installer==2026.9.1                                                       
 pbs-installer==2026.9.1                                                       
 pkginfo==1.13                                                                 
 platformdirs==4.11.7                                                          
 pyproject-hooks==1.2.0                                                        

Poetry (version 2.4.3)

Hatch (2022, Ofek Lev / PSF core) — full workflow, полностью PEP 621. Сильная сторона — матрица окружений: в [tool.hatch.envs] описываешь несколько сред (default, test, docs, …), в каждой свой набор зависимостей и Python-версия, дальше hatch run test:pytest прогоняет тесты в каждой из них. Аналог tox из старых времён (старый инструмент того же класса, до сих пор используется).

!uv tool run --with hatch hatch --version
 Resolving dependencies...                                                     
 Resolving dependencies...                                                     
 Resolving dependencies...                                                     
 Resolving dependencies...                                                     
 hatch==1.18.0                                                                 
 click==8.5.0                                                                  
 hatchling==1.32.0                                                             
 httpx2==2.12.0                                                                
 httpcore2==2.12.0                                                             
 httpcore2==2.12.0                                                             
 hyperlink==21.0.0                                                             
 keyring==25.7.0                                                               
 packaging==26.3                                                               
 pexpect==4.9.0                                                                
 platformdirs==4.11.7                                                          
 pyproject-hooks==1.2.0                                                        
 python-discovery==1.6.0                                                       
 rich==15.0.0                                                                  
 shellingham==1.5.4                                                            
 tomli-w==1.2.0                                                                
 tomlkit==0.15.1                                                               
 userpath==1.9.2                                                               

Hatch, version 1.18.0

PDM (2020, Frost Ming) — начинался с эксперимента PEP 582 (__pypackages__ вместо venv, идея как node_modules). Сейчас — обычный CLI + lock, PEP 621 native.

!uv tool run --with pdm pdm --version
 Resolving dependencies...                                                     
 Resolving dependencies...                                                     
 Resolving dependencies...                                                     
 Resolving dependencies...                                                     
 pdm==2.29.0                                                                   
 argcomplete==3.7.2                                                            
 blinker==1.9.0                                                                
 packaging==26.3                                                               
 platformdirs==4.11.7                                                          
 rich==15.0.0                                                                  
 virtualenv==21.7.8                                                            
 pyproject-hooks==1.2.0                                                        
 unearth==0.18.3                                                               
 dep-logic==0.7.2                                                              
 findpython==0.8.0                                                             
 tomlkit==0.15.1                                                               
 shellingham==1.5.4                                                            
 python-dotenv==1.2.3                                                          
 resolvelib==1.2.1                                                             
 installer==1.0.1                                                              
 truststore==0.10.4                                                            
 hishel==1.3.1                                                                 

PDM, version 2.29.0

uv (2024, Astral) — новый инструмент, вобрал лучшее из остальных. Совместим с pip: команды uv pip install, uv pip list работают ровно как pip, без правок скриптов. Плюс полный workflow: uv init, uv add, uv sync, uv run, uv publish, uv tool install (аналог pipx), uv python install (аналог pyenv).

Одна и та же операция в разных инструментах:

Действие

pip + venv

poetry

hatch

pdm

uv

Init

python -m venv .venv

poetry new

hatch new

pdm init

uv init

Add dep

pip install X + правка [project]

poetry add X

hatch add X

pdm add X

uv add X

Install

pip install .

poetry install

hatch install

pdm install

uv sync

Lock

pip freeze (не PEP)

poetry lock

нет своего

pdm lock

uv lock

Run

.venv/bin/python

poetry run

hatch run

pdm run

uv run

Publish

twine upload

poetry publish

hatch publish

pdm publish

uv publish

Poetry lock несовместим

poetry.lock — только poetry читает. hatch/pdm/uv/pip не понимают. То же обратно — poetry не читает чужие lock. Значит перейти с poetry на что-то другое = потерять lock, перегенерировать заново.

Poetry ещё встречает [tool.poetry]

Legacy poetry-проекты используют свой [tool.poetry] вместо стандартного [project] PEP 621. Внешне похоже, но синтаксис зависимостей отличается (^1.0 вместо >=1.0,<2.0 и т.п.). Poetry 2+ поддерживает [project], но старые проекты часто ещё на [tool.poetry].

Советы:

  • Новый проектuv. Один инструмент, быстрый, совместим с pip.

  • Наследованный poetry — оставайся. Миграция редко того стоит.

  • hatch — хорошо для сложных проектов с несколькими environments (dev, docs, integration test с разными Python-версиями).

  • pdm — нишевый. Единственная причина брать — если уже используешь.

Backend и manager — независимые оси

hatchling.build (backend, собирает wheel) и hatch (manager, оркестрирует venv/deps/publish) — разные вещи, названия просто похожие. У нас в pyproject.toml hatchling.build как backend, а uv как manager — работает без проблем.

То же и с poetry: poetry.core.masonry.api — это только backend, его можно использовать без CLI poetry. Или взять backend flit_core плюс manager uv. Backend и manager меняются независимо, важно только чтобы оба понимали pyproject.toml.


6. Static tooling#

Два инструмента, оба с первого дня курса: 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)
Overwriting /tmp/bad.py

Линтер:

!uv run --no-project --with ruff ruff check /tmp/bad.py || true
]8;;https://docs.astral.sh/ruff/rules/multiple-imports-on-one-line\E401]8;;\ [*] Multiple imports on one line
 --> /tmp/bad.py:1:1
  |
1 | import os,sys
  | ^^^^^^^^^^^^^
2 | from typing import  List, Dict
  |
help: Split imports
  |
  - import os,sys
1 + import os
2 + import sys
3 | from typing import  List, Dict
  |

]8;;https://docs.astral.sh/ruff/rules/unsorted-imports\I001]8;;\ [*] Import block is un-sorted or un-formatted
 --> /tmp/bad.py:1:1
  |
1 | / import os,sys
2 | | from typing import  List, Dict
  | |______________________________^
3 |
4 |   def greet( name:str )->str :
  |
help: Organize imports
  |
  - import os,sys
  - from typing import  List, Dict
1 + import os
2 + import sys
3 + from typing import Dict, List
4 +
5 |
  |

]8;;https://docs.astral.sh/ruff/rules/unused-import\F401]8;;\ [*] `os` imported but unused
 --> /tmp/bad.py:1:8
  |
1 | import os,sys
  |        ^^
2 | from typing import  List, Dict
  |
help: Remove unused import
  |
  - import os,sys
1 | from typing import  List, Dict
  |

]8;;https://docs.astral.sh/ruff/rules/unused-import\F401]8;;\ [*] `sys` imported but unused
 --> /tmp/bad.py:1:11
  |
1 | import os,sys
  |           ^^^
2 | from typing import  List, Dict
  |
help: Remove unused import
  |
  - import os,sys
1 | from typing import  List, Dict
  |


]8;;https://docs.astral.sh/ruff/rules/deprecated-import\UP035]8;;\ `typing.List` is deprecated, use `list` instead
 --> /tmp/bad.py:2:1
  |
1 | import os,sys
2 | from typing import  List, Dict
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3 |
4 | def greet( name:str )->str :
  |

]8;;https://docs.astral.sh/ruff/rules/deprecated-import\UP035]8;;\ `typing.Dict` is deprecated, use `dict` instead
 --> /tmp/bad.py:2:1
  |
1 | import os,sys
2 | from typing import  List, Dict
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3 |
4 | def greet( name:str )->str :
  |

]8;;https://docs.astral.sh/ruff/rules/unused-import\F401]8;;\ [*] `typing.List` imported but unused
 --> /tmp/bad.py:2:21
  |
1 | import os,sys
2 | from typing import  List, Dict
  |                     ^^^^
3 |
4 | def greet( name:str )->str :
  |
help: Remove unused import
  |
1 | import os,sys
  - from typing import  List, Dict
2 |
  |

]8;;https://docs.astral.sh/ruff/rules/unused-import\F401]8;;\ [*] `typing.Dict` imported but unused
 --> /tmp/bad.py:2:27
  |
1 | import os,sys
2 | from typing import  List, Dict
  |                           ^^^^
3 |
4 | def greet( name:str )->str :
  |
help: Remove unused import
  |
1 | import os,sys
  - from typing import  List, Dict
2 |
  |

Found 8 errors.
[*] 6 fixable with the `--fix` option.

Autofix:

!uv run --no-project --with ruff ruff check --fix /tmp/bad.py 2>&1 | tail -3
Found 5 errors (5 fixed, 0 remaining).

Форматтер (аналог black):

!uv run --no-project --with ruff ruff format /tmp/bad.py && cat /tmp/bad.py
1 file reformatted
def greet(name: str) -> str:
    result = "Hello, " + name
    return result


x = greet("world")
print(x)

Конфиг:

[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 allowed в тестах

Не ставь select = ["ALL"]

Там есть правила, которые противоречат друг другу — один включённый запрещает то, что требует другой, и линтер начинает ругаться на любой код. Начинай с базового набора, расширяй по мере надобности.

mypy strict#

Без strict — mypy молчит на многом:

%%writefile /tmp/untyped.py
def greet(name):
    return "Hi " + name

greet(42)  # int вместо str - но mypy без strict пропустит
Overwriting /tmp/untyped.py

Тихо (--config-file=/dev/null — чтобы не подхватил конфиг курса course-content/pyproject.toml, где strict уже включён):

!uv run --no-project --with mypy mypy --config-file=/dev/null /tmp/untyped.py 2>&1 | grep -v "No \[mypy\]" || true
Success: no issues found in 1 source file

Со strict — ловит:

!uv run --no-project --with mypy mypy --config-file=/dev/null --strict /tmp/untyped.py 2>&1 | grep -v "No \[mypy\]" || true
/tmp/untyped.py:1: error: Function is missing a type annotation  [no-untyped-def]
/tmp/untyped.py:4: error: Call to untyped function "greet" in typed context  [no-untyped-call]
Found 2 errors in 1 file (checked 1 source file)

Fix — типы:

%%writefile /tmp/typed.py
def greet(name: str) -> str:
    return "Hi " + name

greet("world")
Overwriting /tmp/typed.py
!uv run --no-project --with mypy mypy --config-file=/dev/null --strict /tmp/typed.py 2>&1 | grep -v "No \[mypy\]"
Success: no issues found in 1 source file

Конфиг:

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

[[tool.mypy.overrides]]
module = ["legacy.*"]           # gradual: пока не готовы
ignore_errors = true

[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"] # чужие либы без stubs
ignore_missing_imports = true

Глубоко про типы — Лекция 2.

pre-commit — hooks локально (опционально)#

Хочется чтобы ruff/mypy проверяли код до push’а, не в CI? Стандартный инструмент — pre-commit. Ставит git-hook, который перед git commit прогоняет любые проверки. Пример .pre-commit-config.yaml:

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.13.0
    hooks:
      - id: mypy
        additional_dependencies: [pytest]

Установка:

uv pip install pre-commit
pre-commit install

В курсе не требуется

Мы не ставим pre-commit как часть шаблона задач: студенту это не обязательно. Кто хочет, настраивает у себя в форке; коммитить .pre-commit-config.yaml в публичный репо не нужно, это твой личный workflow.


Материалы#