Импорты и пакеты: import, __init__.py, sys.path

Импорты и пакеты: import, init.py, sys.path

В первых уроках мы импортировали стандартные модули - import os, from json import dumps (сами эти модули разберём в уроках про os и pathlib и datetime и json). Но как это работает под капотом? Где Python ищет модули, что такое пакет, чем отличаются абсолютные импорты от относительных? В этом уроке - подробный разбор системы импортов.

Модуль vs пакет

  • Модуль - один файл .py с Python-кодом
  • Пакет - директория с файлом __init__.py, содержащая модули и/или подпакеты

Пример структуры:

Структура пакета myapp: init.py, main.py, под-пакеты utils (http.py, string.py) и models (user.py, order.py)

myapp это пакет, myapp.utils подпакет, myapp.utils.http модуль.

init.py - маркер пакета

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

# myapp/__init__.py
__version__ = "1.0.0"
from .models.user import User    # реэкспорт
from .models.order import Order

Тогда снаружи можно:

from myapp import User, Order, __version__

С Python 3.3 появились namespace packages - директории без __init__.py могут быть пакетами для определённых сценариев. Но для обычных проектов всегда добавляй __init__.py - это явно и предсказуемо.

import - формы

# Импорт модуля целиком
import os
import json as J             # с alias

# Импорт конкретных объектов
from os import path, getcwd
from os.path import join, exists

# Импорт всего (плохая практика)
from os import *

# Импорт пакета
import myapp.utils.http
myapp.utils.http.get(...)

# С alias
from myapp.utils.http import get as http_get

Конвенция: предпочитай явные импорты from x import y для краткости в коде, но import x для модулей с короткими часто используемыми именами.

Где Python ищет модули - sys.path

При import foo Python ищет:

  1. Built-in модули (compiled in)
  2. Frozen модули (компиляция в исполняемый файл)
  3. По sys.path - список директорий
import sys
print(sys.path)
# ['', '/usr/lib/python3.12', '/usr/lib/python3.12/site-packages', ...]

sys.path обычно содержит (и именно его подменяет активированное виртуальное окружение):

  • Пустая строка '' - текущая директория (где запущен скрипт)
  • Путь к каталогу запущенного скрипта
  • PYTHONPATH переменная окружения
  • Стандартные пути site-packages

Можно модифицировать в runtime:

sys.path.insert(0, "/my/custom/path")
import my_module   # будет искаться в добавленном пути тоже

Кеширование импортов

Python кеширует импортированные модули в sys.modules:

import sys

import json
print("json" in sys.modules)   # True

# Повторный import не перезагружает
import json   # уже в кеше, просто привязка имени

Это значит:

  • Изменения в исходнике модуля не подхватываются автоматически
  • Для перезагрузки - importlib.reload(module)
  • Циклические импорты могут странно вести себя

Абсолютные импорты

Полный путь от корня пакета:

# myapp/models/user.py
from myapp.utils.string import normalize
from myapp.utils.http import get

Преимущества:

  • Явные - сразу видно откуда модуль
  • Не ломаются при переименовании файла
  • Работают везде

Это рекомендуемый стиль в современном Python (PEP 8).

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

Путь относительно текущего модуля:

# myapp/models/user.py
from ..utils.string import normalize   # .. означает родитель
from .order import Order                # . означает текущий пакет

. это текущий пакет, .. родитель, ... дедушка.

Преимущества:

  • Короче для глубоко вложенных модулей
  • Удобно при перемещении пакета (внутренние импорты не ломаются)

Недостатки:

  • Только внутри пакета (не работают в скриптах)
  • Менее явные
  • Усложняют рефакторинг

В сообществе разногласия. Многие предпочитают абсолютные везде для consistency.

Запуск модуля как скрипта - python -m

python -m myapp.main

Запускает myapp/main.py как скрипт. Это лучше чем python myapp/main.py - правильно настраивает sys.path и __package__ для относительных импортов.

Также через -m запускают модули стандартной библиотеки:

python -m http.server 8000
python -m json.tool < data.json   # форматирование JSON
python -m venv .venv
python -m pip install requests

name == "main"

При импорте модуля его __name__ равно имени модуля (например "myapp.main"). При прямом запуске - "__main__". Это позволяет различать:

# myapp/main.py

def run():
    print("running app")

if __name__ == "__main__":
    run()

Когда модуль импортируется, run() не вызывается. При python -m myapp.main - вызывается.

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

# a.py
from b import B

class A:
    def use_b(self):
        return B()

# b.py
from a import A   # циклическая зависимость!

class B:
    def use_a(self):
        return A()

При импорте a Python начинает загружать его, доходит до from b import B, начинает загружать b, тот пытается импортировать a который ещё не завершил загрузку - получаем неполный модуль или ImportError.

Решения:

  • Reorganize код - выделить общее в третий модуль
  • Lazy import внутри функции (импортируем при первом вызове):
class A:
    def use_b(self):
        from b import B   # импорт отложен
        return B()
  • TYPE_CHECKING для type hints:
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from b import B   # импорт только для типов, не в runtime

class A:
    def use_b(self) -> "B":
        from b import B
        return B()

ImportError vs ModuleNotFoundError

# Модуль не найден
import nonexistent
# ModuleNotFoundError: No module named 'nonexistent'

# Объект не найден в существующем модуле
from os import nonexistent_function
# ImportError: cannot import name 'nonexistent_function' from 'os'

ModuleNotFoundError это подкласс ImportError (с Python 3.6). Различие помогает в отладке.

Поиск конкретного импорта

import importlib.util

spec = importlib.util.find_spec("requests")
if spec is None:
    print("requests not installed")
else:
    print(f"Found at: {spec.origin}")

Удобно для опциональных зависимостей или диагностики.

all - публичный API модуля

# myapp/utils/__init__.py

from .http import get, post, delete
from .string import normalize, slugify
from .internal import _private_helper

__all__ = ["get", "post", "delete", "normalize", "slugify"]

__all__ контролирует что экспортируется при from myapp.utils import *. Подчёркивание-приватные не включаются по умолчанию, но __all__ делает интерфейс явным.

Также используется типизаторами и tools для понимания публичного API.

site-packages - где живут зависимости

python -c "import site; print(site.getsitepackages())"
# ['/usr/lib/python3.12/site-packages', '~/.local/lib/python3.12/site-packages']

site-packages - стандартная директория для third-party библиотек. pip install ставит туда. В venv своя site-packages внутри .venv/lib/python3.X/site-packages/.

Editable install для разработки

pip install -e .

Устанавливает текущий проект в editable mode: изменения в исходниках сразу видны без переустановки. Под капотом - линк в site-packages указывает на твой код. Удобно для разработки пакетов которые сам же пишешь.

Распространённые ошибки

1. import * от модуля

from utils import *   # тащит ВСЁ

Импортирует всё что не начинается с _. Загрязняет namespace, ломает явность, конфликты имён. Используй только в очень специфических случаях (interactive REPL, REPL).

2. Относительный импорт в запускаемом скрипте

# myapp/main.py - запускаем как `python myapp/main.py`
from .utils import helper   # ImportError: attempted relative import with no known parent

Относительные импорты работают только если модуль импортирован как часть пакета. Запускай через python -m myapp.main или используй абсолютные импорты.

3. Циклический import

Обсудили выше. Reorganize или lazy import.

4. Подмена встроенных модулей

# project/json.py - своя реализация
# project/main.py
import json   # импортирует НАШУ json.py, не stdlib!

Не называй свои модули как стандартные библиотеки. На многих системах работает (импорт через sys.path), но создаёт путаницу и ломает code completion.

5. Большие импорты на module level

# на module level - выполнится при любом импорте модуля
import slow_library   # 2 секунды импорта
heavy_initialization()   # ещё 5 секунд

Импорты модуля выполняются один раз при первом import, но это первый раз может быть медленным. Тяжёлую инициализацию выноси в функции или lazy import.

Сравнение с Go

В Go импорт похож но строже:

import (
    "fmt"
    "encoding/json"
    "myapp/utils"
)
  • Все импорты явные в начале файла
  • Нет циклических импортов (compile error)
  • Нет относительных импортов (всегда абсолютные)
  • Неиспользуемые импорты - compile error

Python более гибкий, но требует дисциплины. Go более строгий, что упрощает поддержку больших проектов.

Реальный пример структуры

Src-layout: my_backend содержит pyproject.toml, README.md, src/my_backend (api, models, repositories, utils) и tests директорию с тестами

Структура src/my_backend/ (src-layout) - современная практика. Тесты отдельно, чтобы не попадали в пакет.

Мини-задание

  1. Создай свой мини-пакет:

Структура мини-пакета mypackage: init.py с реэкспортом, core.py и под-пакет utils с init.py и helpers.py

# mypackage/__init__.py
from .core import main_function
__all__ = ["main_function"]

# mypackage/core.py
from .utils.helpers import normalize

def main_function(text):
    return normalize(text) + "!"

# mypackage/utils/helpers.py
def normalize(text):
    return text.strip().lower()

Использование:

from mypackage import main_function
print(main_function("  Hello  "))   # hello!
  1. Запуск модуля как скрипта:
# mypackage/__main__.py
from .core import main_function

if __name__ == "__main__":
    print(main_function("Hello from script"))

Запуск: python -m mypackage

  1. Lazy import:
def heavy_operation():
    # Тяжёлая библиотека загружается только когда функция вызвана
    import pandas as pd
    return pd.DataFrame({"a": [1, 2, 3]})

Что дальше

Освоили систему импортов. В следующем уроке - packaging: pyproject.toml углублённо, build wheel, публикация в PyPI, entry points.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.