Виртуальные среды и пакетные менеджеры
Модули и импорты
Прежде чем говорить о пакетных менеджерах, нужно разобраться в том, как Python вообще находит и загружает код. Без понимания механизма импортов невозможно осмысленно управлять зависимостями — ведь пакетный менеджер в конечном счёте просто кладёт файлы туда, где Python сможет их найти.
Модуль в Python — это файл с исходным кодом. Имя модуля доступно через __name__, а сам модуль является объектом и может быть присвоен переменной.
Синтаксис импорта
Python предлагает несколько способов импорта. Каждый из них — компромисс между удобством и явностью:
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 * импортирует только то, что перечислено в этом списке:
__all__ = ['_hidden_variable']
int_variable = 1
_hidden_variable = 2
from module import *
print(_hidden_variable) # OK — есть в __all__
print(int_variable) # NameError! — нет в __all__
from module import * — плохая практика по нескольким причинам:
- Линтеры не видят, что именно импортируется, и не дают подсказок
- Можно случайно перезаписать существующую переменную в текущем пространстве имён
- По умолчанию не импортирует имена, начинающиеся с
_
Всегда используйте явные импорты. Если нужно экспортировать из модуля определённый набор — определите __all__.
Механизм импорта
Модули импортируются один раз — повторный import не перезагружает модуль. Это принципиально важно: если вы изменили файл модуля, но уже импортировали его, Python продолжит использовать старую версию:
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 есть магическая команда, которая автоматически перезагружает изменённые модули перед выполнением каждой ячейки:
%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 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его не увидят - После загрузки накладных расходов нет: адаптивная специализация байткода убирает проверку прокси после нескольких обращений
Управление без правки исходников:
python -X lazy_imports=all app.py # все импорты ленивые
export PYTHON_LAZY_IMPORTS=all # то же через переменную окружения
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__:
__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__". Когда файл импортируется как модуль — в имя модуля. Это позволяет разделить код библиотеки и код запуска:
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:
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:
- Стандартная библиотека — встроенные модули (например,
sys,os) - Директория запускаемого скрипта — папка, в которой лежит
.pyфайл - Переменная окружения
PYTHONPATH— дополнительные пути, заданные пользователем - Директории site-packages — установленные сторонние библиотеки
import sys
for path in sys.path:
print(path)
sys.path — обычный список, его можно модифицировать в рантайме:
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 следует такому порядку:
- Ищет
itemкак атрибут вpackage(определённый в__init__.py) - Если не нашёл — ищет
itemкак подмодуль внутри пакета - Если ничего не нашёл — выбрасывает
ImportError
import item.subitem.subsubitem
Каждый элемент кроме последне го должен быть пакетом. Последний может быть модулем или пакетом, но не может быть классом или функцией.
Относительные импорты
Внутри пакетов можно использовать относительные импорты с точками:
from . import echo # из текущего пакета (sound.effects)
from .. import formats # из родительского пакета (sound)
from ..filters import equalizer # из соседнего подпакета (sound.filters)
Относительные импорты работают только внутри пакетов. В top-level скрипте (который вы запускаете напрямую) использовать . и .. нельзя.
Циклические импорты
Циклические импорты — одна из самых неочевидных проблем Python. Рассмотрим пример:
from two import func_two
def func_one():
func_two()
from one import func_one
def do_work():
func_one()
def func_two():
print("Hello, world!")
from two import do_work
do_work()
$ python main.py
ImportError: cannot import name 'func_two' from partially initialized module 'two'
Почему это происходит? Разберём по шагам:
main.pyначинает выполнятьfrom two import do_work- Python начинает загружать
two.py— создаёт объект модуляtwoвsys.modules, но кодtwo.pyещё не дошёл до определенияfunc_two - Первая строка
two.py—from one import func_one— запускает загрузкуone.py - Первая строка
one.py—from two import func_two— Python находитtwoвsys.modules(он уже частично загружен), ноfunc_twoещё не определена (кодtwo.pyостановился на строке 1) ImportError!
Решение: заменить from X import Y на import X:
import two # вместо: from two import func_two
def func_one():
two.func_two() # вместо: func_two()
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() — отложенный поиск атрибута в момент вызова функции.