Перейти к основному содержимому

Виртуальные среды и пакетные менеджеры

📺 Слайды к лекции

Модули и импорты​

Прежде чем говорить о пакетных менеджерах, нужно разобраться в том, как Python вообще находит и загружает код. Без понимания механизма импортов невозможно осмысленно управлять зависимостями — ведь пакетный менеджер в конечном счёте просто кладёт файлы туда, где Python сможет их найти.

Модуль в Python — это файл с исходным кодом. Имя модуля доступно через __name__, а сам модуль является объектом и может быть присвоен переменной.

Синтаксис импорта​

Python предлагает несколько способов импорта. Каждый из них — компромисс между удобством и явностью:

imports.py
import numpy                   # полный доступ через numpy.xxx
numpy.array([1, 2, 3])

import numpy as np # псевдоним — короче, но всё ещё явно
np.array([1, 2, 3])

from numpy import array # конкретный объект — без префикса
array([1, 2, 3])

from numpy import * # всё публичное — НЕ рекомендуется!

__all__ — контроль экспорта​

Если модуль определяет переменную __all__, то from module import * импортирует только то, что перечислено в этом списке:

module.py
__all__ = ['_hidden_variable']

int_variable = 1
_hidden_variable = 2
main.py
from module import *

print(_hidden_variable) # OK — есть в __all__
print(int_variable) # NameError! — нет в __all__
Частая ошибка

from module import * — плохая практика по нескольким причинам:

  • Линтеры не видят, что именно импортируется, и не дают подсказок
  • Можно случайно перезаписать существующую переменную в текущем пространстве имён
  • По умолчанию не импортирует имена, начинающиеся с _

Всегда используйте явные импорты. Если нужно экспортировать из модуля определённый набор — определите __all__.

Механизм импорта​

Модули импортируются один раз — повторный import не перезагружает модуль. Это принципиально важно: если вы изменили файл модуля, но уже импортировали его, Python продолжит использовать старую версию:

reload_demo.py
import module
print(module.int_variable) # 1

module.int_variable += 1
import module # НЕ перезагрузит!
print(module.int_variable) # 2 — изменение осталось

import importlib
importlib.reload(module)
print(module.int_variable) # 1 — перезагружено из файла
Важно

importlib.reload() не работает с from module import variable — в этом случае переменная уже скопирована в текущее пространство имён и не обновится.

Jupyter Notebook и autoreload​

В Jupyter есть магическая команда, которая автоматически перезагружает изменённые модули перед выполнением каждой ячейки:

jupyter_autoreload.py
%load_ext autoreload
%autoreload 2 # перезагружать ВСЕ изменённые модули
import my_module # теперь изменения подхватываются автоматически

# вариант с точечным контролем:
%autoreload 1
%aimport my_module # перезагружать только помеченные модули
Какой режим выбрать

Дефолт для исследовательского кода — %autoreload 2: поправили файл, перезапустили ячейку, всё подхватилось. Режим 1 с %aimport пригодится в большом проекте, где автоперезагрузка всего мира стоит дорого или конфликтует с чем-то. Учтите, что autoreload не всесилен: изменения в C-расширениях он не подхватывает, а иногда после сложных правок всё же нужен Restart kernel.

Ленивые импорты: PEP 810 (Python 3.15)​

Главное изменение в системе импортов за много лет. Ключевое слово lazy (soft-ключевое, то есть lazy продолжает работать как обычный идентификатор) откладывает загрузку модуля до первого обращения к имени:

lazy_imports.py
lazy import json
lazy from json import dumps

print('готово') # json ещё НЕ загружен: 'json' in sys.modules → False

data = json.loads('{}') # вот здесь json реально загрузится

Вместо модуля в пространстве имён появляется прокси-объект (types.LazyImportType). Реальная загрузка — реификация — происходит при первом обращении к имени: __import__ вызывается в этот момент, с тем состоянием sys.path и sys.meta_path, которое сложилось к моменту обращения, а не к строке импорта.

Ключевые правила:

  • Только уровень модуля. Внутри функции, тела класса или блока try — SyntaxError (внутри with — можно)
  • lazy from ... import * и импорты из __future__ ленивыми быть не могут
  • Ошибки откладываются. Если модуль не найден или имени в нём нет — ImportError выстрелит при первом обращении, а traceback через exception chaining покажет и строку с lazy import, и место обращения. Прокси при ошибке остаётся и повторит попытку при следующем использовании
  • До реификации модуль живёт в новом множестве sys.lazy_modules, а не в sys.modules — проверки вида if 'json' in sys.modules его не увидят
  • После загрузки накладных расходов нет: адаптивная специализация байткода убирает проверку прокси после нескольких обращений

Управление без правки исходников:

global_modes.sh
python -X lazy_imports=all app.py    # все импорты ленивые
export PYTHON_LAZY_IMPORTS=all # то же через переменную окружения
programmatic.py
import sys

sys.set_lazy_imports("all") # или "normal" / "none"

def my_filter(importing, imported, fromlist):
return imported.startswith("myapp.") # True — лениво, False — жадно

sys.set_lazy_imports_filter(my_filter)

Для кода, который должен работать и на старых версиях, есть мост через __lazy_modules__:

compat.py
__lazy_modules__ = ["json", "pathlib"]  # 3.15+: эти импорты станут ленивыми

import json # ленивый на 3.15+, обычный на более старых версиях
import os # обычный везде
Когда использовать

lazy придуман для ускорения старта: CLI-утилиты и серверы платят только за модули, которые реально понадобились при конкретном запуске. Замена «импорта внутри функции» (который противоречит PEP 8) на ленивый импорт верхнего уровня — ещё один типичный сценарий.

Берегите сайд-эффекты импорта

Код, который полагается на побочные эффекты загрузки модуля — плагины, регистрирующиеся декораторами при импорте, — с lazy работает иначе: пока к модулю никто не обратился, он не загрузится и регистрация не случится. Явно импортируйте нужные подмодули и не рассчитывайте на «импорт ради сайд-эффекта».

Совместимость с инструментами

Среди авторов PEP — Pablo Galindo Salgado и Dino Viehland. Тип-чекеры рассматривают lazy-импорты как обычные для разрешения имён; isort, black, ruff format потребовались минимальные обновления из-за ключевого слова в начале строки. Инструменты, читающие sys.modules для анализа зависимостей, должны учитывать sys.lazy_modules.

__name__ == "__main__" и запуск модуля как скрипта​

Когда Python запускает файл напрямую (python fibo.py), переменная __name__ устанавливается в "__main__". Когда файл импортируется как модуль — в имя модуля. Это позволяет разделить код библиотеки и код запуска:

fibo.py
def fib(n):
a, b = 0, 1
for _ in range(n):
print(a, end=" ")
a, b = b, a + b

if __name__ == "__main__":
import sys
fib(int(sys.argv[1]))

Для более серьёзной обработки аргументов командной строки используйте библиотеку Click:

hello_click.py
import click

@click.command()
@click.option('--count', default=1, help='Number of greetings.')
@click.option('--name', prompt='Your name', help='The person to greet.')
def hello(count, name):
"""Simple program that greets NAME for a total of COUNT times."""
for _ in range(count):
click.echo(f"Hello {name}!")

if __name__ == '__main__':
hello()
Практический совет

Typer — современная альтернатива Click, построенная поверх него. Typer использует type hints Python вместо декораторов, что делает код чище. Для новых проектов стоит рассмотреть именно его.

Поиск модулей​

Python ищет модули в определённом порядке через список sys.path:

  1. Стандартная библиотека — встроенные модули (например, sys, os)
  2. Директория запускаемого скрипта — папка, в которой лежит .py файл
  3. Переменная окружения PYTHONPATH — дополнительные пути, заданные пользователем
  4. Директории site-packages — установленные сторонние библиотеки
check_sys_path.py
import sys
for path in sys.path:
print(path)

sys.path — обычный список, его можно модифицировать в рантайме:

modify_sys_path.py
import sys
sys.path.append('../my_libs')
# теперь Python будет искать модули и в этой директории
Важно

Модификация sys.path в рантайме — хрупкий приём. Он работает, но делает проект зависимым от расположения файлов. Для production-кода лучше правильно настроить виртуальное окружение и установить пакет.

__pycache__ и .pyc файлы​

При импорте модуля CPython проходит через несколько этапов проверки кэша:

После первого импорта модуля Python создаёт в его директории папку __pycache__ с файлами .pyc — скомпилированным байт-кодом. Зачем это нужно?

  • Быстрее импорт — не нужно заново парсить исходный код
  • Скорость выполнения не меняется — исполняется тот же байт-код
  • Файлы .pyc не зависят от платформы, но зависят от версии Python
  • Не нужно хранить в git (добавьте __pycache__/ в .gitignore)

Можно заранее скомпилировать все файлы в директории:

python -m compileall .
примечание

Формат хранения .pyc файлов описан в PEP 3147. До Python 3.2 файлы .pyc лежали рядом с исходниками, что создавало путаницу. Теперь они хранятся в __pycache__/ с именами вида module.cpython-312.pyc, что позволяет нескольким версиям Python сосуществовать.

В Python 3.15 у модулей больше не устанавливается атрибут __cached__ — вместо него используйте __spec__.cached. Там же объявлены устаревшими __loader__ и __package__: их планируется убрать, ориентироваться стоит на __spec__.

Пакеты​

Пакет — это директория с модулями, объединёнными общей логикой.

__init__.py — обычные пакеты​

Файл __init__.py делает директорию пакетом. Он может быть пустым или содержать инициализационный код:

my_package/
├── __init__.py # выполняется при import my_package
├── module_a.py
└── subpackage/
├── __init__.py
└── module_b.py

Namespace packages (без __init__.py)​

Если __init__.py отсутствует, Python распознает директорию как namespace package. Такие пакеты могут быть распределены по нескольким директориям — полезно для крупных проектов, где части пакета поставляются отдельно.

__main__.py — запуск пакета​

Файл __main__.py позволяет запускать пакет целиком через python -m:

python -m tarfile           # запустит tarfile/__main__.py
python -m my_package # запустит my_package/__main__.py

Обратите внимание: используется имя модуля без .py, а не путь к файлу. Python ищет модуль через sys.path, включая встроенные модули и site-packages.

Практический совет

__main__.py — стандартный способ предоставить CLI-интерфейс для пакета. Так работают python -m pytest, python -m pip, python -m json.tool и многие другие.

Как работают импорты​

Понимание правил разрешения имён при импорте помогает избежать ошибок.

from package import item​

Python следует такому порядку:

  1. Ищет item как атрибут в package (определённый в __init__.py)
  2. Если не нашёл — ищет item как подмодуль внутри пакета
  3. Если ничего не нашёл — выбрасывает ImportError

import item.subitem.subsubitem​

Каждый элемент кроме последнего должен быть пакетом. Последний может быть модулем или пакетом, но не может быть классом или функцией.

Относительные импорты​

Внутри пакетов можно использовать относительные импорты с точками:

sound/effects/surround.py
from . import echo             # из текущего пакета (sound.effects)
from .. import formats # из родительского пакета (sound)
from ..filters import equalizer # из соседнего подпакета (sound.filters)
Важно

Относительные импорты работают только внутри пакетов. В top-level скрипте (который вы запускаете напрямую) использовать . и .. нельзя.

Циклические импорты​

Циклические импорты — одна из самых неочевидных проблем Python. Рассмотрим пример:

one.py
from two import func_two

def func_one():
func_two()
two.py
from one import func_one

def do_work():
func_one()

def func_two():
print("Hello, world!")
main.py
from two import do_work
do_work()
$ python main.py
ImportError: cannot import name 'func_two' from partially initialized module 'two'

Почему это происходит? Разберём по шагам:

  1. main.py начинает выполнять from two import do_work
  2. Python начинает загружать two.py — создаёт объект модуля two в sys.modules, но код two.py ещё не дошёл до определения func_two
  3. Первая строка two.py — from one import func_one — запускает загрузку one.py
  4. Первая строка one.py — from two import func_two — Python находит two в sys.modules (он уже частично загружен), но func_two ещё не определена (код two.py остановился на строке 1)
  5. ImportError!

Решение: заменить from X import Y на import X:

one.py
import two                      # вместо: from two import func_two

def func_one():
two.func_two() # вместо: func_two()
two.py
import one                      # вместо: from one import func_one

def do_work():
one.func_one() # вместо: func_one()

def func_two():
print("Hello, world!")
примечание

Почему это работает? При import two Python создаёт объект модуля two в sys.modules и продолжает его загружать. Когда позже (при вызове функции) выполняется two.func_two(), модуль уже полностью загружен. Ключевое отличие: from two import func_two пытается прочитать атрибут прямо сейчас, а two.func_two() — отложенный поиск атрибута в момент вызова функции.

dir() и builtins​

Функция dir() — удобный инструмент для интроспекции:

dir_demo.py
# Без аргументов — все имена в текущей области
>>> dir()
['__builtins__', '__doc__', '__name__', ...]

# С модулем — все атрибуты модуля
>>> import os
>>> dir(os)
['CLD_CONTINUED', 'CLD_DUMPED', 'DirEntry', ...]

# С объектом — все поля и методы
>>> dir([1, 2, 3])
['__add__', '__class__', 'append', 'clear', 'copy', ...]

dir() не показывает встроенные функции. Их список — в модуле builtins:

builtins_demo.py
import builtins
print(dir(builtins))
# ['ArithmeticError', 'AssertionError', ..., 'print', 'range', 'zip']

Виртуальные окружения​

Виртуальные окружения: каждый проект получает свою копию Python с нужными версиями библиотек

Виртуальные окружения решают фундаментальную проблему: разные проекты требуют разных версий одних и тех же библиотек. Без изоляции установка requests==2.31 для одного проекта сломает другой, которому нужен requests==2.25.

Но есть и более серьёзная причина. На Linux-системах Python используется самой ОС — менеджер пакетов, системные утилиты, инструменты администрирования. Установка или обновление библиотек в системный Python может буквально сломать операционную систему.

Никогда не делайте это

sudo pip install в системный Python — путь к поломке ОС. Подробнее: What are the risks of running sudo pip?

PEP 668: системный Python под защитой​

Тезис «не трогайте системный интерпретатор» с 2023 года поддержан и на уровне дистрибутивов. В Debian 12+, Ubuntu 23.04+, Fedora 38+ интерпретатор помечен файлом EXTERNALLY-MANAGED в директории stdlib, и pip отказывается работать с системным Python:

ubuntu_example.sh
$ pip install requests
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to install.

Причина: pip может затереть или удалить файлы, которыми владеет пакетный менеджер ОС (apt/dnf), и сломать системные утилиты, написанные на Python.

  • Флаг-обход --break-system-packages существует, но его название честно описывает последствие — в повседневной работе не используйте
  • Для проектов — виртуальные окружения (venv, uv venv)
  • Для CLI-инструментов (ruff, httpie, yt-dlp) — pipx или uvx: каждое приложение получает собственное изолированное окружение

venv (встроенный)​

Начиная с Python 3.3, в стандартную библиотеку входит модуль venv — минимальный инструмент для создания виртуальных окружений:

venv_commands.sh
# Создание
python -m venv .venv

# Активация (Linux/macOS)
source .venv/bin/activate

# Активация (Windows)
.venv\Scripts\activate

# Деактивация
deactivate

virtualenv​

Сторонний инструмент, который работает быстрее venv и предлагает дополнительные возможности: выбор конкретной версии Python, кеширование wheel-файлов, расширяемость через плагины:

virtualenv_commands.sh
pip install virtualenv
virtualenv .venv --python=python3.12
Практический совет

Всегда создавайте виртуальное окружение для каждого проекта. Основной интерпретатор держите чистым — без сторонних библиотек. Это привычка, которая избавит от множества проблем.

Пакетные менеджеры​

Пакетный менеджер берёт на себя то, что вы не хотите (и не должны) делать вручную: отслеживание версий, кеширование дистрибутивов, компиляцию нативных расширений и поиск пакетов по индексам.

pip и requirements.txt​

pip — стандартный менеджер пакетов Python, встроенный в любую установку:

pip_commands.sh
pip install requests               # установка последней версии
pip install requests==2.31.0 # конкретная версия
pip install -r requirements.txt # установка из файла
pip freeze > requirements.txt # экспорт установленных пакетов
pip uninstall requests # удаление

Как работает pip install​

  1. Скачивает с PyPI wheel (.whl) под вашу платформу и версию Python
  2. Распаковывает wheel прямо в site-packages — это обычный zip-архив, никакого кода на вашей машине не исполняется
  3. Читает зависимости из метаданных внутри пакета (.dist-info/METADATA) и рекурсивно устанавливает их
  4. Если wheel под платформу нет — скачивает sdist и собирает wheel через build-backend из pyproject.toml (PEP 517); для C/C++ расширений нужен компилятор
  5. Пакет становится доступен для импорта
Историческая справка

Раньше pip при установке исполнял setup.py пакета — запуск чужого кода на каждой установке был и медленным, и небезопасным. Сначала установку перевели на wheels, а с pip 23.1 (2023) даже сборка из sdist идёт по протоколу PEP 517: pip вызывает build-backend, а тот уже может запустить setuptools. setup.py сегодня нужен только авторам пакетов — и то лишь как устаревшая альтернатива pyproject.toml.

Важно

pip freeze фиксирует все установленные пакеты, включая транзитивные зависимости. Это хрупкий подход: невозможно отличить прямые зависимости от транзитивных. При обновлении одной библиотеки вы рискуете получить конфликты. Для лучшего контроля используйте инструменты с lock-файлами (Poetry, uv).

Conda​

Почему Conda?​

Conda — не просто менеджер пакетов, а полноценная система управления окружениями. Её ключевые преимущества:

  • Мультиязычность — управляет пакетами Python, C/C++, R, Fortran и других языков
  • Бинарные пакеты — не требует компиляции на стороне пользователя
  • Надёжное разрешение зависимостей — использует SAT-солвер, гарантирующий консистентность окружения

Разница между Conda и pip​

ХарактеристикаpipConda
Встроен в PythonДаНет
Типы пакетовТолько PythonЛюбые (Python, C, R...)
Разрешение зависимостейБазовоеSAT-солвер
Количество пакетовОгромное (PyPI)Меньше, но растёт
СкоростьБыстрыйМедленнее (но libmamba ускоряет)
Может сломать окружениеДаНет (солвер не допустит)

Дистрибутивы Anaconda​

  • Anaconda — полный дистрибутив с сотнями предустановленных библиотек
  • Miniconda — минимальная версия (только conda + Python)
  • Mamba — реализация conda на C++, значительно быстрее оригинала
  • Micromamba — ещё более компактный вариант Mamba, один статический бинарник

Каналы (channels)​

Conda ищет пакеты в каналах — репозиториях с предсобранными пакетами:

  • anaconda (defaults) — базовые библиотеки, курируемые Anaconda Inc.
  • conda-forge — крупнейший community-канал с тысячами пакетов
  • bioconda — специализированный канал для биоинформатики
примечание

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

Как Conda устанавливает пакеты​

Процесс установки пакета в Conda принципиально отличается от pip:

  1. Скачивает repodata.json — полный индекс всех пакетов из подключённых каналов
  2. Формулирует задачу SAT — переводит требования пользователя и зависимости всех пакетов в булеву формулу
  3. Решает SAT — находит набор пакетов, который удовлетворяет всем ограничениям
  4. Устанавливает найденный набор

Каждый ключ в repodata.json — конкретная сборка пакета вида имя-версия-номер_билда, внутри — ограничения на соседей. По этим данным строится граф зависимостей:

repodata.json (фрагмент)
"packages": {
"package_a-1.3.5-0.tar.bz2": {
"depends": ["package_b >=1.2"],
"name": "package_a",
"version": "1.3.5"
},
"package_b-1-0.tar.bz2": { "depends": [], "name": "package_b", "version": "1.0" },
"package_b-1-5.tar.bz2": { "depends": [], "name": "package_b", "version": "1.5" }
}

Именно repodata.json — узкое место conda: для conda-forge он весит сотни мегабайт, поэтому его кешируют и сжимают, а libmamba загружает лениво и параллельно.

О солвере

Начиная с conda 23.x, солвер по умолчанию — libmamba (написан на C++). Это тот же алгоритм, что и в Mamba, но встроенный прямо в conda. Разница в скорости по сравнению со старым Python-солвером — на порядки.

SAT-солвер: как это работает​

SAT (Boolean satisfiability problem) — задача определить, можно ли подобрать значения булевых переменных так, чтобы формула стала истинной. Для произвольных формул в конъюнктивной нормальной форме это NP-полная задача (первая доказанная NP-полная задача, теорема Кука-Левина).

Conda формулирует установку пакетов как SAT: каждый пакет каждой версии — переменная, а зависимости — ограничения. Солвер приоритизирует решения по:

  1. Приоритету канала — defaults важнее conda-forge (если не переопределено)
  2. Версии пакета — новее лучше
  3. Номеру сборки — новее лучше
  4. Таймстемпу — при прочих равных побеждает более свежая сборка

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

Создание пакета для Conda​

Conda-пакет создаётся на основе рецепта (recipe):

  • meta.yaml — метаданные, зависимости, описание
  • build.sh — скрипт сборки для Linux/macOS
  • bld.bat — скрипт сборки для Windows

Сборка запускается через conda-build. Более современная альтернатива — rattler-build, написанный на Rust с более простым форматом рецептов.

Poetry​

Poetry — обёртка поверх pip + setuptools + venv + twine, объединяющая управление зависимостями, виртуальными окружениями и публикацию пакетов в одном инструменте.

poetry_commands.sh
pip install poetry
poetry new my-project # создать проект с структурой
poetry init # инициализировать в существующем проекте
poetry add requests # добавить зависимость
poetry add --group dev pytest # dev-зависимость
poetry install # установить все зависимости
poetry run python main.py # запуск в окружении Poetry
pyproject.toml
[tool.poetry]
name = "my-project"
version = "0.1.0"
description = "My awesome project"

[tool.poetry.dependencies]
python = "^3.12"
requests = "^2.31"

[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
ruff = "^0.4"
Частая ошибка

Устаревший синтаксис [tool.poetry.dev-dependencies] всё ещё работает, но правильный современный способ — [tool.poetry.group.dev.dependencies]. Старый формат может быть удалён в будущих версиях Poetry.

Практический совет

Poetry создаёт poetry.lock — файл с точными версиями всех зависимостей (включая транзитивные). Всегда коммитьте poetry.lock в репозиторий — это гарантия воспроизводимой сборки.

Poetry хорошо подходит для web-проектов и библиотек с PyPI-зависимостями. Для проектов с бинарными или научными зависимостями (NumPy, SciPy, PyTorch) он не лучший выбор — эти пакеты часто требуют платформо-зависимой компиляции, которую conda/pixi решают лучше.

PDM​

PDM — современный менеджер зависимостей, который строго следует стандартам PEP:

  • PEP 621 — метаданные проекта в [project] (а не в проприетарном [tool.pdm])
  • Быстрый резолвер зависимостей
  • Гибкая система плагинов
  • Пользовательские скрипты

PDM — хороший выбор, если важна строгая совместимость со стандартами Python. Функционально он похож на Poetry, но менее популярен.

uv​

uv — менеджер пакетов нового поколения от Astral (создатели ruff). Написан на Rust и работает в 10-100 раз быстрее pip.

Немного истории

До uv существовал проект rye, созданный Армином Ронахером — автором Flask, Jinja2 и Click. Rye был экспериментальным инструментом для управления Python-проектами. В 2024 году команда Astral взяла rye под свою поддержку и полностью заменила его на uv. Rye больше не развивается — все пользователи переведены на uv.

uv — это «всё в одном»: управление версиями Python, виртуальные окружения, зависимости, сборка и публикация:

uv_commands.sh
# Управление версиями Python
uv python install 3.12 # установить Python 3.12
uv python pin 3.12 # зафиксировать версию для проекта

# Создание проекта
uv init my-project # создать новый проект
uv add requests # добавить зависимость
uv add --dev pytest # dev-зависимость

# Запуск
uv run python main.py # запуск в виртуальном окружении
uv run pytest # запуск инструментов

# Инструменты (аналог pipx)
uv tool install ruff # установить CLI-инструмент глобально
uv tool run black . # запустить без установки
uvx black . # uvx — короткий алиас для uv tool run

uv генерирует uv.lock — lock-файл с точными версиями всех зависимостей. Формат стандартизирован и позволяет экспортировать в requirements.txt:

uv_export.sh
uv export -o requirements.txt --no-hashes --no-dev --no-editable --no-emit-project
Практический совет

uv — рекомендуемый выбор для новых проектов с PyPI-зависимостями. Он заменяет pip, venv, pip-tools, pipx и частично poetry — одним быстрым инструментом.

pixi​

pixi — менеджер проектов от prefix.dev, основанной бывшими разработчиками Mamba. Написан на Rust и объединяет лучшее из двух миров:

  • Conda-пакеты — для бинарных, научных и платформо-зависимых библиотек (свой солвер на Rust)
  • PyPI-пакеты — через uv под капотом

Ключевые особенности:

  • Python — просто пакет (как в conda), что упрощает управление версиями
  • Мультиплатформенная валидация — pixi проверяет, что зависимости доступны на всех указанных платформах, и сообщает о проблемах заранее
  • Кросс-платформенные скрипты — через встроенный deno_task_shell
  • pixi.lock — единый lock-файл для всех окружений и платформ
Важно

Сборка в pixi появилась: команда pixi build собирает .conda-пакет из рецепта (бэкенды сборки активно развиваются в 2026). А вот публикация пакетов пока в работе — если вы создаёте библиотеку для распространения, проверяйте актуальное состояние перед выбором инструмента.

Что выбрать?​

Сравнение возможностей пакетных менеджеров:

Сравнительная таблица: pipenv, poetry, conda, hatch, pdm, uv, pixi

Выбор инструмента зависит от типа проекта и зависимостей:

СценарийРекомендация
Библиотека с PyPI-зависимостямиPoetry, PDM или uv (рекомендуется)
Приложение для деплоя (только PyPI)uv
Бинарные / научные зависимостиpixi
Библиотека с бинарными зависимостямиpixi (pixi build уже есть, публикация — в работе)

Сборка и публикация пакетов​

Современный подход: pyproject.toml (PEP 517/518)​

PEP 517 и PEP 518 стандартизировали систему сборки Python-пакетов. Файл pyproject.toml — единственный необходимый конфигурационный файл для современного проекта:

pyproject.toml
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "my-package"
version = "0.1.0"
requires-python = ">=3.12"
description = "My awesome package"
dependencies = ["requests>=2.25"]

[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.4"]
Частая ошибка

build-backend должен быть "setuptools.build_meta", а не "setuptools.backends._legacy:_Backend". Вторая форма — внутренний API, который не предназначен для использования в pyproject.toml.

Сборка и публикация:

build_and_publish.sh
# Сборка — создаёт dist/ с .whl и .tar.gz
python -m build

# Публикация на тестовый PyPI
python -m twine upload --repository testpypi dist/*

# Публикация на production PyPI
python -m twine upload dist/*

Структура проекта:

my-package/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── core.py
└── tests/
└── test_core.py

Устаревший подход: setup.py​

До pyproject.toml пакеты описывались через setup.py и setup.cfg:

setup.py
from setuptools import setup, find_packages

setup(
name="my-package",
version="0.1.0",
packages=find_packages(),
install_requires=["requests>=2.25"],
)
Важно

setup.py и setup.cfg — устаревший подход. Они всё ещё работают, но для новых проектов используйте pyproject.toml. Все современные инструменты (uv, poetry, PDM, pip) полностью поддерживают PEP 517/518.

PEP 723 — встроенные метаданные скрипта​

PEP 723 позволяет указать зависимости прямо внутри Python-скрипта. Это удобно для одиночных скриптов, которые не являются частью проекта:

fetch_data.py
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "requests>=2.31",
# "rich>=13.0",
# ]
# ///

import requests
from rich import print

response = requests.get("https://api.github.com/zen")
print(f"[bold green]{response.text}[/bold green]")

uv автоматически создаёт временное окружение и устанавливает зависимости:

uv run fetch_data.py    # скачает requests и rich, запустит скрипт

Помимо uv, метаданные PEP 723 поддерживают pipx, Poetry, Hatch и PDM. pip дольше всех: в pip 26.0 (начало 2026) появилась установка зависимостей из скрипта — pip install --requirements-from-script fetch_data.py, но запускать скрипт pip по-прежнему не умеет.

Практический совет

PEP 723 отлично подходит для утилитарных скриптов, которые хочется запускать одной командой без настройки окружения и передавать коллегам одним файлом. Скрипт сам описывает свои зависимости — никакого requirements.txt не нужно.

PEP 735 — группы зависимостей​

PEP 735 вводит стандартный (не tool-специфичный) способ описания групп зависимостей в pyproject.toml:

pyproject.toml
[dependency-groups]
test = ["pytest>=8.0", "coverage>=7.0"]
lint = ["ruff>=0.4", "mypy>=1.10"]
dev = [{include-group = "test"}, {include-group = "lint"}]

Преимущество — это кросс-инструментальный стандарт. Вместо [tool.poetry.group.dev.dependencies] или [project.optional-dependencies] используется единый формат, который понимают uv, pip, и другие инструменты.

PEP 751 — стандартный lock-файл pylock.toml​

До недавнего времени каждый инструмент имел собственный формат lock-файла: poetry.lock, uv.lock, pdm.lock — несовместимых друг с другом. PEP 751 (принят в марте 2025) вводит единый формат — pylock.toml:

pylock.toml
lock-version = "1.0"
requires-python = ">=3.12"
created-by = "uv"

[[packages]]
name = "requests"
version = "2.32.3"
wheels = [{ name = "requests-2.32.3-py3-none-any.whl", hashes = { sha256 = "..." } }]

Свойства стандарта:

  • Статичность — установщик должен уметь ставить пакеты без повторного разрешения зависимостей
  • Хэши обязательны для архивов и локальных файлов (в requirements.txt они были опцией) — безопасность по умолчанию
  • Имя pylock.<name>.toml позволяет держать рядом несколько lock-файлов, например для разных сценариев

uv поддерживает и чтение, и генерацию: uv export --format pylock.toml экспортирует uv.lock в стандарт, uv pip install -r pylock.toml — устанавливает из него. pip умеет генерировать такой файл командой pip lock (с версии 25.1), поддержка есть и у Poetry.

примечание

Родные lock-файлы инструментов (uv.lock, poetry.lock) никуда не исчезают — они остаются рабочими форматами с расширенными возможностями. pylock.toml — это формат обмена: один файл, который понимают все инструменты, CI-системы и сканеры уязвимостей.

Создание пакета для Conda​

Conda-пакет создаётся из рецепта:

meta.yaml
package:
name: my-package
version: "0.1.0"

source:
path: .

build:
number: 0
script: python -m pip install . --no-deps

requirements:
host:
- python >=3.12
- pip
- setuptools
run:
- python >=3.12
- requests >=2.25

about:
summary: My awesome package
conda_build.sh
# Классический способ
conda-build .

# Современная альтернатива (Rust, быстрее, проще)
rattler-build build --recipe recipe.yaml

rattler-build использует собственный формат рецептов (YAML, но с другой структурой), который проще и строже, чем meta.yaml от conda-build.

Полезные ссылки​