Pull to refresh
644.87
OTUS
Развиваем технологии, обучая их создателей

Круговой импорт в Python: как он ломает проекты и как его победить

Level of difficultyEasy
Reading time5 min
Views3.1K

Привет, Хабр!

Сегодня говорим о том, что в какой‑то момент словит почти каждый разработчик, особенно если вы не просто пишете скрипты, а строите проекты — будь то Django, Flask или кастомная архитектура с бизнес‑логикой в отдельных слоях. Речь про круговые импорты: они не объявляют о себе заранее, не фейлят весь проект громко и сразу, но подкрадываются исподтишка. И вот вы уже сидите с ошибкой ImportError: cannot import name ... или AttributeError, гуглите часами, тасуете импорты туда‑сюда и ловите дежавю — кажется, это уже было, но где?

Как понять, что вы попали в петлю

Обычно все начинается невинно:

# users.py
from notifications import send_email

def register(...):
    send_email(...)
# notifications.py
from users import get_user_email   # ← бум!

Интерпретатор загружает users, тот требует notifications, а notifications снова требует ещё не инициализированный users. Итог — AttributeError или ImportError, в зависимости от того, в какой фазе успело упасть.

Классические симптомы:

  • В стеке вызовов видите одну и ту же пару файлов, крутящихся как белка в колесе.

  • Переменные‑зомби: объект уже есть в sys.modules, но методы ещё не определены.

  • Flask‑кейс: from app import app внутри файла, который импортируется ещё до создания самого приложения.

Откуда ноги — кейсы

Flask: «app factory» и Blueprint-ы

В книгах по Flask советуют паттерн Application Factory: функцию create_app() плюс Blueprint‑ы. Если же вы решите импортировать приложение на уровне модуля, получите «cannot import name „app“» — классический круговой бум. Лечение: создаем объекты (DB, миграции) без жесткого импорта приложения, а инициализируем их в create_app():

# extensions.py
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()

# __init__.py
def create_app():
    from .extensions import db
    app = Flask(__name__)
    db.init_app(app)
    ...
    return app

Так мы разрываем цепь: extensions знает о db, но не о app; app инициализирует db поздно, когда всё готово.

Django: модели, сигналы, селф-референсы

В Django импортировать модель из другой модели — грешок, особенно в signals.py. Официальный прием:

from django.apps import apps

User = apps.get_model('auth', 'User')

или просто ссылаться строкой в ForeignKey('app.Model'). Так объект будет резолвиться после загрузки моделей, а не во время.

Бизнес-логика: «service layer» спасет

Когда «domain» импортирует «repository», а тот обратно «domain», пора завести service layer и вынести общие типы в отдельный модуль common, который не зависит ни от одного конкретного слоя. Так поток импортов идет строго сверху вниз.

Три кита анти-loop архитектуры

Кит № 1. Локальные импорты — быстро и грязно

Работает, но странно смотрится в код‑ревью. Пример:

def send_email(...):
    from users import get_user_email
    ..

Убираем цикл мгновенно и экономим время старта.

Но при этом легко забыть о них, сложно мокать в тестах. Если функция вызывается часто, импорт будет валить кеш ‑ лечится контролем: if 'users' not in sys.modules: ....

Кит № 2. Lazy imports по PEP 690

С выходом 3.13 включаем глобальный флаг (или from future import lazy_imports) — модули становятся «зомби‑проксами» и инициализируются при первом обращении. Цикл разрывается сам, потому что код, требующий зависимость, будет вызван после полной загрузки.

Ленивые импорты + Cinder дают −40% к времени до первого батча в ML‑воркфлоу — так что профит ощутим.

Кит № 3. Строгое разбиение слоёв

  • apiservicerepositorydrivers

  • «низ» никогда не импортирует «верх».

  • Общие enum и датаклассы — в domain или common.

Схема банальна, но затыкает 90% круговых огрехов.

importlib: когда очень надо

import importlib

def load_plugin(name: str):
    return importlib.import_module(f'app.plugins.{name}')

Подгружаем модули по строке, избегая статического цикла. Отличная точка расширения для систем плагинов.

Иногда модуль уже есть в sys.modules, но экспорт недоступен из‑за раннего падения. Тогда:

module = importlib.reload(sys.modules['users'])

Не самый чистый паттерн, но спасает.

Если вы ещё сидите на 3.10–3.12, пишем свой «ленивый» загрузчик (документация Python привела шикарный пример):

import importlib.util as iu
import sys, types

def lazy_import(name):
    spec = iu.find_spec(name)
    spec.loader = iu.LazyLoader(spec.loader)
    module = iu.module_from_spec(spec)
    sys.modules[name] = module
    spec.loader.exec_module(module)
    return module

Теперь lazy_import("numpy") не прогрузит 200 МБ C‑расширений, пока вы не дёрнете numpy.array.

Код, который не ломается

Enum-ы и DTO — в одном месте

# common/types.py
from enum import Enum
class Role(str, Enum):
    ADMIN = 'admin'
    USER = 'user'

users, auth, payments импортируют только Role, но не друг друга.

Фабрики вместо прямых ссылок

# services/email_service.py
def get_email_sender():
    from adapters.smtp import SmtpSender
    return SmtpSender(...)

Тест легко подменить через monkey‑patch:

monkeypatch.setattr(email_service, 'get_email_sender', FakeSender)

Паттерн «верхний уровень — только интерфейсы»

# repos/__init__.py
from .base import AbstractRepo

service.py импортирует AbstractRepo, а конкретная реализация PgRepo регистрируется через DI‑контейнер. Таким образом, repo «знает» о service? Нет, только наоборот.

Острые уголки

  1. «Локальные импорты — это некрасиво». Согласен, но когда горит, стоит выбрать рабочее и уродливое» вместо «красивое, но лежит».

  2. Lazy imports скрывают ошибки: исключения всплывают не на старте, а в произвольный момент. Логируйте importlib.invalidate_caches() на CI и гоняйте pytest -Werror.

  3. Статический анализ (mypy, pylint) может не видеть динамику. Решение — typing.TYPE_CHECKING и псевдо‑импорт:

    from typing import TYPE_CHECKING
    if TYPE_CHECKING:
        from service import ImportantClass
  4. Рефакторинг сто строк vs. ручка reload. Я за рефакторинг: долгосрочно дешевле.


Если вы дочитали до этого места, скорее всего, уже сталкивались с круговыми импортами или как минимум с болью архитектурных костылей. А теперь представьте: ваш код не просто не ломается — он устойчив, масштабируем и готов к росту команды.

Чтобы к этому прийти — стоит не просто гуглить ошибки, а копать глубже. Мы в OTUS как раз для этого сделали два открытых урока. Без «воды», с практикой и пользой:

А если после уроков вы захотите двигаться дальше — загляните на страницу курса «Специализация Python Developer». Мы постарались сделать программу максимально прикладной и ориентированной на реальную разработку.

Tags:
Hubs:
+4
Comments7

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS