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

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

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

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

Прежде чем говорить о пакетных менеджерах, нужно разобраться в том, как 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 2, а не %autoreload 1. Режим 1 перезагружает только модули, импортированные через %aimport, а 2 — все модули автоматически.

__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 сосуществовать.

Пакеты

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

__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.pyfrom one import func_one — запускает загрузку one.py
  4. Первая строка one.pyfrom 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?

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. Скачивает .whl (wheel) файл с PyPI для вашей платформы
  2. Разархивирует пакет и запускает setup.py (или обрабатывает метаданные из pyproject.toml)
  3. Рекурсивно устанавливает зависимости, указанные в метаданных пакета
  4. Если пакет требует компиляции C/C++ расширений — запускает компилятор (который должен быть установлен)
  5. Пакет становится доступен для импорта
Важно

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. Устанавливает найденный набор
О солвере

Начиная с 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 . # запустить без установки

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 пока не умеет собирать и публиковать пакеты. Модуль сборки находится в активной разработке. Это важное ограничение, если вы создаёте библиотеку для распространения.

Что выбрать?

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

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

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

СценарийРекомендация
Библиотека с PyPI-зависимостямиPoetry, PDM или uv (рекомендуется)
Приложение для деплоя (только PyPI)uv
Бинарные / научные зависимости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, запустит скрипт
Практический совет

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, и другие инструменты.

Создание пакета для 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.

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